From 493e0b37a1ada02fbfbc608462864c42bf0f9bce Mon Sep 17 00:00:00 2001 From: Sergey Korotonozhko <_serezhka_@mail.ru> Date: Sun, 24 May 2026 07:50:38 +0300 Subject: [PATCH] =?UTF-8?q?v1.6.0:=20max=5Fbot=20fixes=20=E2=80=94=20featu?= =?UTF-8?q?re=20keys,=20flush=E2=86=92commit,=20test-run,=20categories,=20?= =?UTF-8?q?broadcast=20page,=20proxy=20error=20handling,=20deploy=20script?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- max_bot/.env.example | 5 + max_bot/CHANGELOG.md | 24 + max_bot/Dockerfile | 16 + max_bot/app/__init__.py | 0 .../app/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 150 bytes .../__pycache__/bot_engine.cpython-314.pyc | Bin 0 -> 14364 bytes .../app/__pycache__/bot_engine.cpython-38.pyc | Bin 0 -> 5752 bytes .../app/__pycache__/config.cpython-314.pyc | Bin 0 -> 2062 bytes max_bot/app/__pycache__/config.cpython-38.pyc | Bin 0 -> 1136 bytes .../__pycache__/conversation.cpython-314.pyc | Bin 0 -> 11116 bytes .../__pycache__/conversation.cpython-38.pyc | Bin 0 -> 4658 bytes .../app/__pycache__/database.cpython-314.pyc | Bin 0 -> 1341 bytes .../app/__pycache__/database.cpython-38.pyc | Bin 0 -> 583 bytes .../__pycache__/file_storage.cpython-38.pyc | Bin 0 -> 1729 bytes max_bot/app/__pycache__/fsm.cpython-314.pyc | Bin 0 -> 10181 bytes max_bot/app/__pycache__/fsm.cpython-38.pyc | Bin 0 -> 2887 bytes max_bot/app/__pycache__/main.cpython-314.pyc | Bin 0 -> 65981 bytes max_bot/app/__pycache__/main.cpython-38.pyc | Bin 0 -> 27083 bytes .../app/__pycache__/max_api.cpython-314.pyc | Bin 0 -> 14931 bytes .../app/__pycache__/max_api.cpython-38.pyc | Bin 0 -> 6804 bytes .../settings_cache.cpython-314.pyc | Bin 0 -> 11361 bytes .../__pycache__/settings_cache.cpython-38.pyc | Bin 0 -> 4949 bytes .../__pycache__/text_renderer.cpython-314.pyc | Bin 0 -> 2657 bytes .../__pycache__/text_renderer.cpython-38.pyc | Bin 0 -> 1315 bytes max_bot/app/bot_engine.py | 268 ++++++ max_bot/app/config.py | 24 + max_bot/app/conversation.py | 142 +++ max_bot/app/database.py | 22 + max_bot/app/file_storage.py | 77 ++ max_bot/app/fsm.py | 209 +++++ max_bot/app/handlers/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 159 bytes .../__pycache__/consent.cpython-314.pyc | Bin 0 -> 4782 bytes .../__pycache__/consent.cpython-38.pyc | Bin 0 -> 2264 bytes .../__pycache__/contact.cpython-38.pyc | Bin 0 -> 1495 bytes .../__pycache__/greeting.cpython-314.pyc | Bin 0 -> 2489 bytes .../__pycache__/greeting.cpython-38.pyc | Bin 0 -> 1188 bytes .../__pycache__/handoff.cpython-314.pyc | Bin 0 -> 2212 bytes .../__pycache__/handoff.cpython-38.pyc | Bin 0 -> 1568 bytes .../__pycache__/inquiry.cpython-314.pyc | Bin 0 -> 8134 bytes .../__pycache__/inquiry.cpython-38.pyc | Bin 0 -> 2820 bytes .../__pycache__/main_menu.cpython-314.pyc | Bin 0 -> 6731 bytes .../__pycache__/main_menu.cpython-38.pyc | Bin 0 -> 3417 bytes .../__pycache__/out_of_hours.cpython-314.pyc | Bin 0 -> 3118 bytes max_bot/app/handlers/consent.py | 60 ++ max_bot/app/handlers/contact.py | 43 + max_bot/app/handlers/features/__init__.py | 0 .../__pycache__/appointment.cpython-38.pyc | Bin 0 -> 1948 bytes .../__pycache__/commercial.cpython-38.pyc | Bin 0 -> 1933 bytes .../__pycache__/my_objects.cpython-38.pyc | Bin 0 -> 1608 bytes .../__pycache__/photo_report.cpython-38.pyc | Bin 0 -> 1854 bytes .../__pycache__/quality_rate.cpython-38.pyc | Bin 0 -> 1243 bytes .../__pycache__/risk_score.cpython-38.pyc | Bin 0 -> 2998 bytes .../__pycache__/sla_status.cpython-38.pyc | Bin 0 -> 1566 bytes max_bot/app/handlers/features/appointment.py | 105 +++ max_bot/app/handlers/features/broadcasts.py | 44 + max_bot/app/handlers/features/commercial.py | 101 ++ max_bot/app/handlers/features/my_objects.py | 39 + .../app/handlers/features/notifications.py | 68 ++ max_bot/app/handlers/features/photo_report.py | 69 ++ max_bot/app/handlers/features/quality_rate.py | 35 + max_bot/app/handlers/features/reminders.py | 48 + max_bot/app/handlers/features/risk_score.py | 100 ++ max_bot/app/handlers/features/sla_status.py | 54 ++ max_bot/app/handlers/features/sla_ticket.py | 155 +++ max_bot/app/handlers/greeting.py | 33 + max_bot/app/handlers/handoff.py | 32 + max_bot/app/handlers/inquiry.py | 122 +++ max_bot/app/handlers/main_menu.py | 103 ++ max_bot/app/handlers/out_of_hours.py | 43 + max_bot/app/integrations/_1c_unf.py | 100 ++ max_bot/app/integrations/__init__.py | 0 .../__pycache__/_1c_unf.cpython-314.pyc | Bin 0 -> 6306 bytes .../__pycache__/_1c_unf.cpython-38.pyc | Bin 0 -> 2592 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 163 bytes .../__pycache__/portal_db.cpython-314.pyc | Bin 0 -> 4319 bytes .../__pycache__/portal_db.cpython-38.pyc | Bin 0 -> 1743 bytes .../__pycache__/yandex_gpt.cpython-314.pyc | Bin 0 -> 6100 bytes .../__pycache__/yandex_gpt.cpython-38.pyc | Bin 0 -> 2469 bytes max_bot/app/integrations/portal_db.py | 57 ++ max_bot/app/integrations/yandex_gpt.py | 80 ++ max_bot/app/keyboards/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 160 bytes .../__pycache__/inline.cpython-314.pyc | Bin 0 -> 5998 bytes .../__pycache__/inline.cpython-38.pyc | Bin 0 -> 3149 bytes max_bot/app/keyboards/inline.py | 90 ++ max_bot/app/main.py | 879 ++++++++++++++++++ max_bot/app/max_api.py | 159 ++++ max_bot/app/max_notifications.py | 148 +++ max_bot/app/models/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 157 bytes .../models/__pycache__/models.cpython-314.pyc | Bin 0 -> 18547 bytes .../models/__pycache__/models.cpython-38.pyc | Bin 0 -> 6467 bytes max_bot/app/models/models.py | 350 +++++++ max_bot/app/settings_cache.py | 129 +++ max_bot/app/text_renderer.py | 34 + max_bot/app/utils/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 156 bytes .../emotion_detector.cpython-314.pyc | Bin 0 -> 2233 bytes max_bot/app/utils/emotion_detector.py | 28 + max_bot/docker-compose.yml | 15 + max_bot/max_bot_deploy.sh | 106 +++ max_bot/nginx/max-aegisone.conf | 52 ++ max_bot/requirements.txt | 12 + max_bot/sql/bot_schema.sql | 205 ++++ .../v1.5.1_broadcast_recipient_ids.sql | 2 + .../v1.5.2_role_menu_permissions.sql | 26 + .../sql/migrations/v1.5.5_tables_and_seed.sql | 121 +++ max_bot/sql/migrations/v1.6.0_fixes.sql | 38 + max_bot/tests/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 152 bytes .../conftest.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 5449 bytes .../test_fsm.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 66950 bytes ...test_handlers.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 24688 bytes .../test_max_api.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 21059 bytes .../test_webhook.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 31991 bytes max_bot/tests/conftest.py | 78 ++ max_bot/tests/test_fsm.py | 168 ++++ max_bot/tests/test_handlers.py | 179 ++++ max_bot/tests/test_max_api.py | 134 +++ max_bot/tests/test_webhook.py | 183 ++++ max_bot/version.txt | 1 + py_service/app/routers/service_pages.py | 309 ++++-- py_service/app/templates/_sidebar.html | 129 +++ .../app/templates/pages/bot_broadcasts.html | 241 +++++ py_service/deploy.sh | 13 +- py_service/tests/permission_config.py | 40 + 127 files changed, 6082 insertions(+), 65 deletions(-) create mode 100644 max_bot/.env.example create mode 100644 max_bot/CHANGELOG.md create mode 100644 max_bot/Dockerfile create mode 100644 max_bot/app/__init__.py create mode 100644 max_bot/app/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/bot_engine.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/bot_engine.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/config.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/config.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/conversation.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/conversation.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/database.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/database.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/file_storage.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/fsm.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/fsm.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/main.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/main.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/max_api.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/max_api.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/settings_cache.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/settings_cache.cpython-38.pyc create mode 100644 max_bot/app/__pycache__/text_renderer.cpython-314.pyc create mode 100644 max_bot/app/__pycache__/text_renderer.cpython-38.pyc create mode 100644 max_bot/app/bot_engine.py create mode 100644 max_bot/app/config.py create mode 100644 max_bot/app/conversation.py create mode 100644 max_bot/app/database.py create mode 100644 max_bot/app/file_storage.py create mode 100644 max_bot/app/fsm.py create mode 100644 max_bot/app/handlers/__init__.py create mode 100644 max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/consent.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/consent.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/contact.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/handoff.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/main_menu.cpython-314.pyc create mode 100644 max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc create mode 100644 max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc create mode 100644 max_bot/app/handlers/consent.py create mode 100644 max_bot/app/handlers/contact.py create mode 100644 max_bot/app/handlers/features/__init__.py create mode 100644 max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/commercial.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/quality_rate.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc create mode 100644 max_bot/app/handlers/features/appointment.py create mode 100644 max_bot/app/handlers/features/broadcasts.py create mode 100644 max_bot/app/handlers/features/commercial.py create mode 100644 max_bot/app/handlers/features/my_objects.py create mode 100644 max_bot/app/handlers/features/notifications.py create mode 100644 max_bot/app/handlers/features/photo_report.py create mode 100644 max_bot/app/handlers/features/quality_rate.py create mode 100644 max_bot/app/handlers/features/reminders.py create mode 100644 max_bot/app/handlers/features/risk_score.py create mode 100644 max_bot/app/handlers/features/sla_status.py create mode 100644 max_bot/app/handlers/features/sla_ticket.py create mode 100644 max_bot/app/handlers/greeting.py create mode 100644 max_bot/app/handlers/handoff.py create mode 100644 max_bot/app/handlers/inquiry.py create mode 100644 max_bot/app/handlers/main_menu.py create mode 100644 max_bot/app/handlers/out_of_hours.py create mode 100644 max_bot/app/integrations/_1c_unf.py create mode 100644 max_bot/app/integrations/__init__.py create mode 100644 max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc create mode 100644 max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc create mode 100644 max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/integrations/__pycache__/portal_db.cpython-314.pyc create mode 100644 max_bot/app/integrations/__pycache__/portal_db.cpython-38.pyc create mode 100644 max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc create mode 100644 max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc create mode 100644 max_bot/app/integrations/portal_db.py create mode 100644 max_bot/app/integrations/yandex_gpt.py create mode 100644 max_bot/app/keyboards/__init__.py create mode 100644 max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc create mode 100644 max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc create mode 100644 max_bot/app/keyboards/inline.py create mode 100644 max_bot/app/main.py create mode 100644 max_bot/app/max_api.py create mode 100644 max_bot/app/max_notifications.py create mode 100644 max_bot/app/models/__init__.py create mode 100644 max_bot/app/models/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/models/__pycache__/models.cpython-314.pyc create mode 100644 max_bot/app/models/__pycache__/models.cpython-38.pyc create mode 100644 max_bot/app/models/models.py create mode 100644 max_bot/app/settings_cache.py create mode 100644 max_bot/app/text_renderer.py create mode 100644 max_bot/app/utils/__init__.py create mode 100644 max_bot/app/utils/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc create mode 100644 max_bot/app/utils/emotion_detector.py create mode 100644 max_bot/docker-compose.yml create mode 100644 max_bot/max_bot_deploy.sh create mode 100644 max_bot/nginx/max-aegisone.conf create mode 100644 max_bot/requirements.txt create mode 100644 max_bot/sql/bot_schema.sql create mode 100644 max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql create mode 100644 max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql create mode 100644 max_bot/sql/migrations/v1.5.5_tables_and_seed.sql create mode 100644 max_bot/sql/migrations/v1.6.0_fixes.sql create mode 100644 max_bot/tests/__init__.py create mode 100644 max_bot/tests/__pycache__/__init__.cpython-314.pyc create mode 100644 max_bot/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc create mode 100644 max_bot/tests/__pycache__/test_fsm.cpython-314-pytest-9.0.3.pyc create mode 100644 max_bot/tests/__pycache__/test_handlers.cpython-314-pytest-9.0.3.pyc create mode 100644 max_bot/tests/__pycache__/test_max_api.cpython-314-pytest-9.0.3.pyc create mode 100644 max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc create mode 100644 max_bot/tests/conftest.py create mode 100644 max_bot/tests/test_fsm.py create mode 100644 max_bot/tests/test_handlers.py create mode 100644 max_bot/tests/test_max_api.py create mode 100644 max_bot/tests/test_webhook.py create mode 100644 max_bot/version.txt create mode 100644 py_service/app/templates/_sidebar.html create mode 100644 py_service/app/templates/pages/bot_broadcasts.html diff --git a/max_bot/.env.example b/max_bot/.env.example new file mode 100644 index 0000000..51f4cad --- /dev/null +++ b/max_bot/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone +APP_ENV=production +LOG_LEVEL=info +WEBHOOK_SECRET=change-me-to-random-secret +WEBHOOK_PATH=/webhook diff --git a/max_bot/CHANGELOG.md b/max_bot/CHANGELOG.md new file mode 100644 index 0000000..5c5cfa8 --- /dev/null +++ b/max_bot/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## v1.6.0 (2026-05-24) + +### Bug Fixes +- **Features**: Исправлен feature_key `auto_notifications` → `notifications` (фича теперь отображается в меню) +- **Features**: Исправлен feature_key `commercial_offer` → `commercial` (унификация с кодом) +- **Заявки**: Исправлена критическая ошибка — `db.flush()` вместо `db.commit()` во всех write-эндпоинтах (данные не сохранялись) +- **Заявки**: Кнопка "Создать" теперь корректно сохраняет заявку в БД +- **Тест-прогон**: Исправлено — тестовые сообщения бота теперь содержат `role: "bot"`, отображаются в результате +- **Router proxy**: Добавлен `try/except` во все прокси-эндпоинты (500 → 503 с сообщением) +- **Mojibake**: Исправлена кириллица на всех страницах бота (добавлен `page_name` в `ctx()`) +- **Broadcast**: Добавлена колонка `recipient_ids` в таблицу `bot_broadcasts` + +### New Features +- **Рассылки**: Добавлена отдельная страница **Рассылки** в меню Чат-бот Max (роут + шаблон + sidebar) +- **Категории**: Полностью обновлены категории обращений (7 новых с иконками и описаниями) +- **Permissions**: Добавлены `bot_*` права для роли `engineer` в `role_menu_permissions` +- **Deploy**: Улучшен `max_bot_deploy.sh` — теперь использует `docker exec -i` pipe вместо `docker cp` +- **Deploy**: Главный `deploy.sh` теперь включает развёртывание max_bot + +### Infrastructure +- **Database**: `get_db()` теперь корректно вызывает `session.commit()` при выходе (аналогично py_service) +- **Permission config**: Добавлены все пропущенные маршруты в `ROUTE_PERMISSIONS` diff --git a/max_bot/Dockerfile b/max_bot/Dockerfile new file mode 100644 index 0000000..c8e21e8 --- /dev/null +++ b/max_bot/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt + +COPY . . + +RUN mkdir -p /app/uploads/bot + +RUN python -m pytest tests/ -v --tb=short || true + +EXPOSE 8002 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--workers", "2"] diff --git a/max_bot/app/__init__.py b/max_bot/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/__pycache__/__init__.cpython-314.pyc b/max_bot/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4feef110931f2a8024f66396932ba71af3c05885 GIT binary patch literal 150 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 I4pQWQ0Xm)OTL1t6 literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/bot_engine.cpython-38.pyc b/max_bot/app/__pycache__/bot_engine.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0c30c41c7b1496832bca0e4c1fd91347d742064 GIT binary patch literal 5752 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 a77ke`hyJqin3hpeYIaDE=|7WneenMg91s-% literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/config.cpython-314.pyc b/max_bot/app/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..911d7b34618477bcdecd12639719eead6d8050d9 GIT binary patch literal 2062 zcmZ`)-ESL35Z^nW?Q+ePpAb|-9?o^@WLBzDwRiO&L6l4R=U~W%#ly;)e>K1Z z(Y8G9UT_cWlf{LfO=L3FhG| zx@&HkoR{sQ3wp?6HEQTa$uzpoys01V_n_(!aGr2UBV4`_Jc&oDTfzKhb;YKw3p?nWyjtx7wgI?)4pBz*ZBUzg$K|BL7`G=>lKqyF+^#_Dby^)!<62t z=~j1Tl+yh&bGcQvP1}KG6}*7SGDWP1sA(Hc0UAgoD0R8c%LdgsXXU*bH(g5ECpcPKEit5>VLh8Ogy>Rl1&#Lq_H{TMuM1jMJL z6+d!cZA~5jNj<{G!NQJ!Fp#4jPYPf>9tU+07o`T-Avfd;pAb?2>lArMDoH7_CB0AH zmNn^B*we-G0QL=FWdQpJa9{uj2e69RJ9OBUVt6IJoxYOZ%Ft_CRs_?Vn>4erBSP8S zRhrH0WU^xT-OQEMTyC9irngtcNKf0!ENhu<5x%yW&81hi=*pTVV%O6fE1CBIZ>`hq z+m2E_>!Sbl7C>{m!DP}LcnQZ|cgb106Zz|Ik|flT*nwgGKB*0uLJGfc~( zbY5auSr_~4vd11nW<23hL_q_J8l*LmZ)98Xna0&tVydy$nwSH1@?2x%$@t`#i(ji> zU3#26-AtZ-oIKx5p8t0KQF8eQ^HFmB$MI}qy>)c@p^|7#r5-AWUnsKwM4NzkkpNom zkq$BVwU=5RE9$j8X8&IeuSvVG(ithd5oDGGO$6Y7RAcr7$;$#Vdx1ihjE9(1D;8hXA;&R8a zT!xogr*!E3-``g+c7BPM*g06mz}>ST{`5(b^fL)PB@<7{>rct_^T4ol;{Hd^2~6!{ VL=N9eK9_-$10Rti&k%V*{tX_N47=K9f3Cz(R)qKSBd@2$R>q@HTB&P9%$9C)e;lW}TKK%s@N7!Kq zyXQa?d)((G=5~P|1`2?Ez$GAJaR9iC!vcqZE4WhNY8Sy=<+Y`A8C^!KwuxA@iGXaq z@YlQcVhz@Wi#3-nZmO4_gU!~8fz7cVj>}+pSo=mkAd}j?vMz!8e zWlYlv1nj(i_438tE)yN}&U$;jQJwKyW=YEILY2g)R_$#|M_+*%{TMJyS- zkMBrrK>EV`uG1{rj*CQF*^LQSIh)|u_8o_g@2E!rj@wODw^0pitX~CSjHh#DnuJgx zWKQ#`6q*oKw*HDWwpCQk8fX<{xm#AiH^``h;;6{_Y=(cNR;XqFD}k|xX`8m{M0dYu zZsCKc9zadzN<66bz74fJbF@UDt^++BVELtCd>5u{dzs33F5+CuR4c3EDy2Q;!MtI2RmHdVd1xz0xVopeQTLrc D_$CUL literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/conversation.cpython-314.pyc b/max_bot/app/__pycache__/conversation.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec9ee8bf735216109aa24b23f0db4f39c1d96f68 GIT binary patch literal 11116 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 literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/conversation.cpython-38.pyc b/max_bot/app/__pycache__/conversation.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d897b70befde3836ada0edfbbb7ca93f6d68b65b GIT binary patch literal 4658 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 Bh&ccN literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/fsm.cpython-314.pyc b/max_bot/app/__pycache__/fsm.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96817afc965cedac694e54a8bfb3250146517e22 GIT binary patch literal 10181 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 o3_uNpIQ;z5XIeSZ!>NfMc0F5XQzqnB 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 OU{E6eRd7sI)%Z8P+elRa literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/main.cpython-38.pyc b/max_bot/app/__pycache__/main.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..201294939db831988dfcac1e483791fd45ec0dbc GIT binary patch literal 27083 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 Zdb|_n%^&AJhQ5RcEOOw&@it|R{|}glLzDmj literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/max_api.cpython-314.pyc b/max_bot/app/__pycache__/max_api.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13ccfa135315982795e62fcf5f1de647c72dd4f2 GIT binary patch literal 14931 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 R+84}+P3+DG3_|Mv{{g(3n1%oV literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/max_api.cpython-38.pyc b/max_bot/app/__pycache__/max_api.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f7e43f66cfd72c489707b4dee49dee68d45f27c GIT binary patch literal 6804 zcmbVQ+ix7z8K2wE&aT%R$GKoa2+N&?B!)o3EkHuzTxhbSi4m}bPRDzW?XhPsoSDsK zHI~{Ys(2{0>PyvPR9k(@8$xQOQk6dNSgCTPK8^ZNsZaa?eaQp#_nnzp&#o7W7<;~* zIrE)!zVm&*?{dx?vGngLMfnfCWG@{r$8h_LAWUJZr_|$LwXVu{t**&m zy{^k&qi*0=^K#8xJ*N_{?wQSeJujbC&#GJ8SS#@2x>_G&M!j@eVL4{rRG4`~tB*7F zl2XloiP@A|RgEm>1Uzt?oSt9r1a7YBZ1fyZ$BAF7JN6%rdFALlwZn6|1hof$VFDmRz%EVrsMljUz3b&FZ7a8s#6P}y2! zV`v>iFC8tVRh^BqGFnQ>_ibzfZ{zG$UdEr!x6%0cE3%!d7Td*kV~z=SqW`VP?pn>U zDRwv7x3iN;dy(x~HP}6DFIp$jR%G`=!hP&MjJ<=+poRX5?0#@Pz#atGPIgMlL^C~v z)`!_6Xx+t5Co&Y-qiB7MRnfW|JhZ~c*%KJ=uH%f1_M+u;kY-)`~Cz?(hS4lX;Lj(jEzcCKR<+ikglZEwd2 zz6GMFru^&e$}aX`$_Hqhf(KpEY5<}Ai=JtzU*Ps1011^PxYM%bPtwzpD)t82n!ZlU zmESHYA`dn!nI?_QhS&Bvi}H@!CVz>>VZDp!dv_1YpW*!fl8b$@)V};()7F)~%dBNZ7BF2c__*6IjzzMp3WCZ+L5ar}>(S&eZpWEner=KM> ztk`qNI|Py@TrHL~%BS%~?+Pz61ou0UG1q3-hY$>iB(pz=?mkICF=8)#tgM#QN96C6 zYHfi{(rXL0$q{6nAUwjJNYqSQ*J8^{gh%@6nc1E`-Q;lmN5M79vm%pRy2+o!_Y}B0 z23#xtUW=63suktCJ{Pu&5Yt$60z8O)=opPTLEtnNoA4DsGATVfL9~;(zBA`>+jCpX zc1Q3lF25R?t#;tfUyorgLnQ{Fi$W6x9=WKScmUXSS6#6}L+lKV&|=zACCrIRn8UM< zXVc%$s6B`0y#JhhHjgTd0Elu1{ z?PkXjPSZ!Qre%tPY(o&@Ehf!VEyf`0k2*ASX5vR^fWt&6P-J7dkI`UGIrG!_Cew(b zTdDzUWp2$#-!v1EekjgoN2%uGbxK3$=ej}AZuwFbu?;ENrWY-&Z^Os%LZBooGJREB zP!WWnf7nHX4H2?3dd}7A+uRnA!tG^#eXi{YCZ59pw=_5?qHjIlajtu9he3T(K_t$m zRe z-k7~p?Bi}4N+IBhDhRA7bN@@!u&Fxo0I72Oytl%Q}lZqzB&k$0wyzl}!yj-VD zg6K>aJ@?m1;WLd_@tOvrmLS-Omx)NZwT#?Df{w+ZNn-sibV%dZ)PSKfOka`;QUH

C*x98&D^f4AClY zaE#pc@GUI1pc-BZlqZG?A>$Yf%a`|zCjX!%j0)+AAtjK5dnbl|1DBCEn1OEUK(=Ac zOkm9-z`4ck0=-ssftJEBhzGlBrSIW0iy#?7GK^#dNfgO3B&U#^Me+iYmyo=U)iYdSgUJtUWrj3POXBnE_y26++>BEblg@(FmH`JzGEXuL;A%?g&Nj1K3D zTWYLGNX?d8O6PAof2q^iqMaF1IZ-I*zplgCNqg0q(<{NbS}TRrY>lPQ9_I$F6jHPG zmeToq<#cw?K6lL7=Rez^_tFV{^t(KYp#Miw@*nDyhOinUv`3xOg8wVpBSlY3@IN}0 zGI~gHYsb{txHdc;d2P_QjyzWKhN;&H!ZAg7Obt=GRr)Pt;c;{FLqKNjJkS3($GyV6 f?EN3E;&azwo?rechxGG=?Ylg`;ueRLoaz4oAMoR^ literal 0 HcmV?d00001 diff --git a/max_bot/tests/conftest.py b/max_bot/tests/conftest.py new file mode 100644 index 0000000..280f53d --- /dev/null +++ b/max_bot/tests/conftest.py @@ -0,0 +1,78 @@ +import os +os.environ["WEBHOOK_SECRET"] = "test-secret" +os.environ["DATABASE_URL"] = "sqlite+aiosqlite://" +os.environ["APP_ENV"] = "test" + +import asyncio +from typing import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import Settings, get_settings +from app.database import get_db +from app.models.models import Base + +get_settings.cache_clear() +from app.main import app # noqa: E402 + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def test_engine(): + engine = create_async_engine("sqlite+aiosqlite://", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture +async def test_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + TestSession = async_sessionmaker(test_engine, expire_on_commit=False) + async with TestSession() as session: + yield session + + +@pytest_asyncio.fixture +async def client(test_session: AsyncSession) -> AsyncGenerator: + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + yield test_session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +def mock_max_api(): + mock = AsyncMock() + mock.send_message = AsyncMock(return_value={"ok": True}) + mock.send_message_with_keyboard = AsyncMock(return_value={"ok": True}) + mock.send_file = AsyncMock(return_value={"ok": True}) + mock.get_file_url = AsyncMock(return_value="https://example.com/file") + mock.send_contact_request = AsyncMock(return_value={"ok": True}) + mock.answer_callback = AsyncMock(return_value={"ok": True}) + mock.get_me = AsyncMock(return_value={"ok": True, "bot": "test"}) + mock.subscribe_webhook = AsyncMock(return_value={"ok": True}) + mock.verify_webhook_secret = MagicMock(return_value=True) + mock.verify_contact_hash = MagicMock(return_value=True) + return mock + + +@pytest.fixture +def bot(): + return None diff --git a/max_bot/tests/test_fsm.py b/max_bot/tests/test_fsm.py new file mode 100644 index 0000000..f9a6656 --- /dev/null +++ b/max_bot/tests/test_fsm.py @@ -0,0 +1,168 @@ +from app.fsm import BotState, FSM, STATE_TRANSITIONS + + +class TestFSM: + def test_initial_state(self): + FSM.clear_all() + assert FSM.get_state(1) == BotState.IDLE + + def test_set_and_get_state(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + assert FSM.get_state(1) == BotState.NAME_REQUEST + + def test_reset(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + FSM.reset(1) + assert FSM.get_state(1) == BotState.IDLE + + def test_valid_transition(self): + FSM.clear_all() + result = FSM.transition(1, "start") + assert result == BotState.NAME_REQUEST + + def test_invalid_transition_returns_same(self): + FSM.clear_all() + # From IDLE, "unknown" should keep us at IDLE + result = FSM.transition(1, "unknown_event") + assert result == BotState.IDLE + + def test_can_transition_true(self): + FSM.clear_all() + assert FSM.can_transition(1, "start") is True + + def test_can_transition_false(self): + FSM.clear_all() + assert FSM.can_transition(1, "unknown_event") is False + + def test_full_greeting_flow(self): + FSM.clear_all() + assert FSM.transition(1, "start") == BotState.NAME_REQUEST + assert FSM.transition(1, "name_provided") == BotState.CONSENT_REQUEST + assert FSM.transition(1, "consent_given") == BotState.CONTACT_REQUEST + assert FSM.transition(1, "contact_provided") == BotState.INQUIRY_REQUEST + assert FSM.transition(1, "inquiry_provided") == BotState.CATEGORY_SELECT + assert FSM.transition(1, "category_selected") == BotState.CONFIRMATION + assert FSM.transition(1, "done") == BotState.IDLE + + def test_consent_refused_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.CONSENT_REQUEST) + assert FSM.transition(1, "consent_refused") == BotState.IDLE + + def test_handoff_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.HANDOFF_REQUEST) + assert FSM.transition(1, "confirmed") == BotState.HANDOFF_CONFIRMATION + assert FSM.transition(1, "done") == BotState.IDLE + + def test_handoff_cancel_to_menu(self): + FSM.clear_all() + FSM.set_state(1, BotState.HANDOFF_REQUEST) + assert FSM.transition(1, "cancel") == BotState.MAIN_MENU + + def test_main_menu_new_inquiry(self): + FSM.clear_all() + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "new_inquiry") == BotState.NAME_REQUEST + + def test_main_menu_features(self): + FSM.clear_all() + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_risk_score") == BotState.FEATURE_RISK_SCORE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_sla_status") == BotState.FEATURE_SLA_STATUS + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_photo_report") == BotState.FEATURE_PHOTO_REPORT + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_appointment") == BotState.FEATURE_APPOINTMENT + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_quality_rate") == BotState.FEATURE_QUALITY_RATE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_commercial") == BotState.FEATURE_COMMERCIAL + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_my_objects") == BotState.FEATURE_MY_OBJECTS + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "sla_ticket_create") == BotState.SLA_TICKET_CREATE + + def test_feature_done_returns_to_menu(self): + FSM.clear_all() + FSM.set_state(1, BotState.FEATURE_RISK_SCORE) + assert FSM.transition(1, "done") == BotState.MAIN_MENU + FSM.set_state(1, BotState.FEATURE_RISK_SCORE) + assert FSM.transition(1, "cancel") == BotState.MAIN_MENU + + def test_out_of_hours_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "leave_message") == BotState.INQUIRY_REQUEST + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "callback") == BotState.NAME_REQUEST + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "cancel") == BotState.IDLE + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "main_menu") == BotState.MAIN_MENU + + def test_category_select_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.CATEGORY_SELECT) + assert FSM.transition(1, "category_selected") == BotState.CONFIRMATION + FSM.set_state(1, BotState.CATEGORY_SELECT) + assert FSM.transition(1, "cancel") == BotState.IDLE + + def test_kb_search_found(self): + FSM.clear_all() + FSM.set_state(1, BotState.KB_SEARCH) + assert FSM.transition(1, "found") == BotState.MAIN_MENU + FSM.set_state(1, BotState.KB_SEARCH) + assert FSM.transition(1, "not_found") == BotState.INQUIRY_REQUEST + + def test_waiting_1c_response(self): + FSM.clear_all() + FSM.set_state(1, BotState.WAITING_1C_RESPONSE) + assert FSM.transition(1, "received") == BotState.MAIN_MENU + FSM.set_state(1, BotState.WAITING_1C_RESPONSE) + assert FSM.transition(1, "timeout") == BotState.IDLE + + def test_rating_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.AWAITING_RATING) + assert FSM.transition(1, "rating_provided") == BotState.IDLE + + def test_new_user_start_via_callback(self): + FSM.clear_all() + # Simulate callback: from MAIN_MENU, cancel goes to IDLE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "cancel") == BotState.IDLE + + def test_idle_handoff_event(self): + FSM.clear_all() + assert FSM.transition(1, "handoff") == BotState.HANDOFF_REQUEST + + def test_edit_from_confirmation(self): + FSM.clear_all() + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_name") == BotState.NAME_REQUEST + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_contact") == BotState.CONTACT_REQUEST + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_inquiry") == BotState.INQUIRY_REQUEST + + def test_clear_all(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + FSM.set_state(2, BotState.CONSENT_REQUEST) + FSM.clear_all() + assert FSM.get_state(1) == BotState.IDLE + assert FSM.get_state(2) == BotState.IDLE + + def test_all_states_have_transitions(self): + for state in BotState: + assert state in STATE_TRANSITIONS, f"{state} missing from STATE_TRANSITIONS" + + def test_all_transitions_go_to_valid_states(self): + for state, events in STATE_TRANSITIONS.items(): + for event, target in events.items(): + assert target in BotState.__members__.values(), \ + f"{state}.{event} -> {target} is not a valid BotState" diff --git a/max_bot/tests/test_handlers.py b/max_bot/tests/test_handlers.py new file mode 100644 index 0000000..5a59f16 --- /dev/null +++ b/max_bot/tests/test_handlers.py @@ -0,0 +1,179 @@ +import pytest +from unittest.mock import AsyncMock + +from app.fsm import BotState, FSM +from app.handlers.greeting import handle_start +from app.handlers.consent import handle_consent_request, handle_consent_given, handle_consent_refused +from app.handlers.main_menu import handle_main_menu +from app.settings_cache import SettingsCache + + +@pytest.mark.asyncio +async def test_handle_start_new_user(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 12345, "first_name": "Иван", "last_name": "Петров"} + + await SettingsCache.refresh(test_session) + await handle_start(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + call_args = mock_max_api.send_message_with_keyboard.call_args + assert call_args[0][0] == 12345 + assert FSM.get_state(12345) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_start_returning_user(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user, create_conversation + FSM.clear_all() + user_data = {"user_id": 67890, "first_name": "Пётр"} + + user = await get_or_create_user(test_session, 67890, "Пётр") + await test_session.flush() + await create_conversation(test_session, user.id) + await test_session.flush() + + await SettingsCache.refresh(test_session) + await handle_start(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(67890) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_consent_request(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 111, "first_name": "Анна"} + + await SettingsCache.refresh(test_session) + await handle_consent_request(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(111) == BotState.CONSENT_REQUEST + + +@pytest.mark.asyncio +async def test_handle_consent_given(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + FSM.clear_all() + user_data = {"user_id": 222} + + user = await get_or_create_user(test_session, 222) + await test_session.flush() + + await handle_consent_given(bot, user_data, test_session, mock_max_api) + + assert user.consent_given is True + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(222) == BotState.CONTACT_REQUEST + + +@pytest.mark.asyncio +async def test_handle_consent_refused(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + FSM.clear_all() + user_data = {"user_id": 333} + + user = await get_or_create_user(test_session, 333) + await test_session.flush() + + await SettingsCache.refresh(test_session) + await handle_consent_refused(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(333) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_new_inquiry_without_consent(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 444, "payload": "new_inquiry"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + # Should go to consent request + mock_max_api.send_message_with_keyboard.assert_called_once() + + +@pytest.mark.asyncio +async def test_handle_main_menu_cancel(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 555, "payload": "cancel"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(555) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_unknown_payload(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 666, "payload": "unknown_command"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(666) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_handoff(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + from app.handlers.handoff import handle_handoff_request + FSM.clear_all() + user_data = {"user_id": 668, "payload": "handoff"} + + await get_or_create_user(test_session, 668, "Тест") + await test_session.flush() + + await SettingsCache.refresh(test_session) + # Call directly (goes to out_of_hours if after hours, or handoff template) + await handle_handoff_request(bot, user_data, test_session, mock_max_api) + + # Either send_message (handoff) or send_message_with_keyboard (out_of_hours) + assert mock_max_api.send_message.called or mock_max_api.send_message_with_keyboard.called + + +@pytest.mark.asyncio +async def test_handle_out_of_hours(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours + FSM.clear_all() + user_data = {"user_id": 777} + + await SettingsCache.refresh(test_session) + await handle_out_of_hours(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(777) == BotState.OUT_OF_HOURS + + +@pytest.mark.asyncio +async def test_handle_out_of_hours_leave_message(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours_callback + FSM.clear_all() + user_data = {"user_id": 888, "text": "тестовое обращение"} + FSM.set_state(888, BotState.OUT_OF_HOURS) + + await SettingsCache.refresh(test_session) + await handle_out_of_hours_callback(bot, user_data, test_session, mock_max_api, "leave_message") + + # handle_inquiry completes the flow → ends at CONFIRMATION + assert FSM.get_state(888) == BotState.CONFIRMATION + + +@pytest.mark.asyncio +async def test_handle_out_of_hours_callback_request(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours_callback + FSM.clear_all() + user_data = {"user_id": 999} + FSM.set_state(999, BotState.OUT_OF_HOURS) + + await SettingsCache.refresh(test_session) + await handle_out_of_hours_callback(bot, user_data, test_session, mock_max_api, "callback") + + mock_max_api.send_message.assert_called_once() + assert FSM.get_state(999) == BotState.NAME_REQUEST diff --git a/max_bot/tests/test_max_api.py b/max_bot/tests/test_max_api.py new file mode 100644 index 0000000..42b1e34 --- /dev/null +++ b/max_bot/tests/test_max_api.py @@ -0,0 +1,134 @@ +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + +from app.max_api import MaxAPIClient + + +@pytest.fixture +def api(): + return MaxAPIClient(token="test-token") + + +@pytest.mark.asyncio +async def test_send_message(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_message(user_id=123, text="Hello") + assert result == {"ok": True} + api._request.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_message_with_chat_id(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_message(chat_id=456, text="Hello") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_send_message_with_keyboard(api): + api.send_message = AsyncMock(return_value={"ok": True}) + buttons = [[{"type": "message", "text": "Test", "payload": "test"}]] + result = await api.send_message_with_keyboard(user_id=123, text="Menu", buttons=buttons) + assert result == {"ok": True} + api.send_message.assert_called_once() + _, kwargs = api.send_message.call_args + assert kwargs["user_id"] == 123 + assert kwargs["text"] == "Menu" + assert len(kwargs["attachments"]) == 1 + + +@pytest.mark.asyncio +async def test_get_me(api): + api._request = AsyncMock(return_value={"ok": True, "bot": "test"}) + result = await api.get_me() + assert result["bot"] == "test" + + +@pytest.mark.asyncio +async def test_subscribe_webhook(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.subscribe_webhook(url="https://example.com/webhook", secret="secret123") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_answer_callback(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.answer_callback(callback_id="cb_123", text="Thanks") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_send_file(api): + api.upload_file = AsyncMock(return_value={"file_id": "file_123"}) + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_file(user_id=123, file_path="/tmp/test.pdf", caption="Here") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_get_file_url(api): + api._request = AsyncMock(return_value={"url": "https://example.com/file.pdf"}) + result = await api.get_file_url("file_123") + assert result == "https://example.com/file.pdf" + + +@pytest.mark.asyncio +async def test_send_contact_request(api): + api.send_message = AsyncMock(return_value={"ok": True}) + result = await api.send_contact_request(user_id=123) + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_edit_message(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.edit_message(message_id=1, text="Updated") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_get_subscriptions(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.get_subscriptions() + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_unsubscribe_webhook(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.unsubscribe_webhook() + assert result == {"ok": True} + + +def test_verify_webhook_secret_valid(api): + assert api.verify_webhook_secret("secret123", "secret123") is True + + +def test_verify_webhook_secret_invalid(api): + assert api.verify_webhook_secret("wrong", "secret123") is False + + +def test_verify_webhook_secret_empty(api): + assert api.verify_webhook_secret("anything", "") is True + + +def test_verify_contact_hash(api): + result = api.verify_contact_hash("test vcf", "access_token", "some_hash") + assert result is False or result is True + + +def test_headers(api): + headers = api._headers() + assert headers["Authorization"] == "test-token" + assert headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_close(api): + session = AsyncMock() + session.closed = False + api._session = session + await api.close() + session.close.assert_awaited_once() diff --git a/max_bot/tests/test_webhook.py b/max_bot/tests/test_webhook.py new file mode 100644 index 0000000..522a1d7 --- /dev/null +++ b/max_bot/tests/test_webhook.py @@ -0,0 +1,183 @@ +import json + +import pytest + + +class TestWebhook: + @pytest.mark.asyncio + async def test_health_endpoint(self, client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["bot"] == "София" + + @pytest.mark.asyncio + async def test_webhook_missing_secret(self, client): + payload = {"update_type": "bot_started", "user": {"user_id": 123}} + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_webhook_with_secret(self, client): + payload = {"update_type": "bot_started", "user": {"user_id": 12345}} + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + @pytest.mark.asyncio + async def test_webhook_message_created(self, client): + payload = { + "update_type": "message_created", + "user": {"user_id": 99999, "first_name": "Тест"}, + "message": {"body": {"text": "Привет"}}, + } + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + @pytest.mark.asyncio + async def test_webhook_invalid_json(self, client): + response = await client.post( + "/webhook", + content="not json", + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is False + + @pytest.mark.asyncio + async def test_webhook_message_callback(self, client): + payload = { + "update_type": "message_callback", + "user": {"user_id": 88888}, + "callback": {"payload": "cancel", "id": "cb_001"}, + } + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + +class TestAPI: + @pytest.mark.asyncio + async def test_api_settings(self, client): + response = await client.get("/api/bot/settings") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_features(self, client): + response = await client.get("/api/bot/features") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + @pytest.mark.asyncio + async def test_api_categories(self, client): + response = await client.get("/api/bot/categories") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_templates(self, client): + response = await client.get("/api/bot/templates") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_users(self, client): + response = await client.get("/api/bot/users") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "users" in data + + @pytest.mark.asyncio + async def test_api_conversations(self, client): + response = await client.get("/api/bot/conversations") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + @pytest.mark.asyncio + async def test_api_analytics(self, client): + response = await client.get("/api/bot/analytics") + assert response.status_code == 200 + data = response.json() + assert "total_users" in data + + @pytest.mark.asyncio + async def test_api_test_run(self, client): + response = await client.post( + "/api/bot/test-run", + content=json.dumps({"scenario": "new_dialog", "messages": []}), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["scenario"] == "new_dialog" + + @pytest.mark.asyncio + async def test_api_test_run_out_of_hours(self, client): + response = await client.post( + "/api/bot/test-run", + content=json.dumps({"scenario": "out_of_hours", "messages": []}), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["scenario"] == "out_of_hours" + # Should have at least a bot response and mention out_of_hours template + assert len(data.get("conversation", [])) >= 1 + + @pytest.mark.asyncio + async def test_api_broadcast_recipients(self, client): + response = await client.get("/api/bot/broadcast/recipients") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "users" in data + + @pytest.mark.asyncio + async def test_api_broadcast_history(self, client): + response = await client.get("/api/bot/broadcast/history") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "broadcasts" in data + + @pytest.mark.asyncio + async def test_api_kb(self, client): + response = await client.get("/api/bot/kb") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) diff --git a/max_bot/version.txt b/max_bot/version.txt new file mode 100644 index 0000000..9075be4 --- /dev/null +++ b/max_bot/version.txt @@ -0,0 +1 @@ +1.5.5 diff --git a/py_service/app/routers/service_pages.py b/py_service/app/routers/service_pages.py index 726222f..10ac3a4 100644 --- a/py_service/app/routers/service_pages.py +++ b/py_service/app/routers/service_pages.py @@ -49,7 +49,8 @@ def ctx(request, user, **kw): "role_permissions": getattr(request.state, 'role_permissions', {}), "role_override_active": original_user is not None, "original_role": original_user["role"] if original_user else None, - "available_roles": available_roles.get(user["role"], [user["role"]]) if user else []} + "available_roles": available_roles.get(user["role"], [user["role"]]) if user else [], + "page_name": kw.get("active_page", "")} c.update(kw) return c @@ -76,11 +77,13 @@ async def users_create(request: Request, db: AsyncSession = Depends(get_db), use if not login or not password: return RedirectResponse(url="/service/users", status_code=302) from app.auth import hash_password + max_id_val = form.get("max_user_id") new_user = User( login=login, password_hash=hash_password(password), full_name=form.get("full_name"), role=form.get("role", "technician"), phone=form.get("phone", ""), email=form.get("email", ""), + max_user_id=int(max_id_val) if max_id_val and max_id_val.strip() else None, is_active=form.get("is_active", "1") == "1", ) db.add(new_user); await db.flush() @@ -102,6 +105,8 @@ async def users_edit(request: Request, db: AsyncSession = Depends(get_db), user: u.role = form.get("role", u.role) u.phone = form.get("phone", u.phone) u.email = form.get("email", u.email) + max_id_val = form.get("max_user_id") + u.max_user_id = int(max_id_val) if max_id_val and max_id_val.strip() else None if "is_active" in form: u.is_active = form["is_active"] == "1" from app.auth import hash_password @@ -1243,104 +1248,85 @@ import aiohttp MAX_BOT_URL = "http://127.0.0.1:8002" +async def _proxy(method, path, json_body=None): + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: + async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp: + return JSONResponse(await resp.json()) + except aiohttp.ClientError as e: + return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503) + except Exception as e: + return JSONResponse({"error": str(e), "ok": False}, status_code=500) + + @router.get("/service/api/bot/settings") async def api_bot_settings(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/settings") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/settings") @router.post("/service/api/bot/settings/save") async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/settings/save", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/settings/save", body) @router.get("/service/api/bot/templates") async def api_bot_templates(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/templates") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/templates") @router.post("/service/api/bot/templates/save") async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/templates/save", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/templates/save", body) @router.get("/service/api/bot/features") async def api_bot_features(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/features") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/features") @router.post("/service/api/bot/features/toggle") async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/features/toggle", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/features/toggle", body) @router.get("/service/api/bot/categories") async def api_bot_categories(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/categories") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/categories") @router.post("/service/api/bot/categories/create") async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/create", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/create", body) @router.post("/service/api/bot/categories/edit") async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/edit", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/edit", body) @router.post("/service/api/bot/categories/delete") async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/delete", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/delete", body) @router.get("/service/api/bot/kb") async def api_bot_kb(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/kb") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/kb") @router.post("/service/api/bot/kb/create") async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/create", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/create", body) @router.post("/service/api/bot/kb/edit") async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/edit", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/edit", body) @router.post("/service/api/bot/kb/delete") async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/delete", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/delete", body) @router.get("/service/api/bot/conversations") async def api_bot_conversations(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/conversations") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/conversations") @router.get("/service/api/bot/users") async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), @@ -1350,22 +1336,16 @@ async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends params = f"?page={page}&limit={limit}&sort_by={sort_by}&sort_dir={sort_dir}" if consent: params += f"&consent={consent}" - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/users{params}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/users{params}") @router.get("/service/api/bot/analytics") async def api_bot_analytics(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/analytics") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/analytics") @router.post("/service/api/bot/test-run") async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/test-run", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/test-run", body) @router.get("/service/api/bot/broadcast/recipients") async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), @@ -1373,20 +1353,221 @@ async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: params = f"?page={page}&limit={limit}" if q: params += f"&q={q}" - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/recipients{params}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/broadcast/recipients{params}") @router.get("/service/api/bot/broadcast/history") async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), page: int = Query(1), limit: int = Query(20)): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/history?page={page}&limit={limit}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/broadcast/history?page={page}&limit={limit}") @router.post("/service/api/bot/broadcast") async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/broadcast", json=body) as resp: - return JSONResponse(await resp.json()) \ No newline at end of file + return await _proxy("POST", "/api/bot/broadcast", body) + + +@router.get("/service/bot-settings/handoffs") +async def bot_handoffs_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_handoffs.html", ctx(request, user=user, active_page="bot-handoffs", title="Handoff-диалоги")) + + +@router.get("/service/bot-settings/handoffs/poll") +async def bot_handoffs_poll(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/handoffs") + + +@router.post("/service/bot-settings/handoffs/{handoff_id}/take") +async def bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/take") + + +@router.get("/service/bot-settings/notifications") +async def bot_notifications_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_notifications.html", ctx(request, user=user, active_page="bot-notifications", title="Уведомления бота")) + + +@router.get("/service/bot-settings/notifications/data") +async def bot_notifications_data(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/notifications") + + +@router.post("/service/bot-settings/notifications/{notif_id}/read") +async def bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/notifications/{notif_id}/read") + + +# ===================== BOT RISK QUESTIONS ===================== + + +@router.get("/service/bot-settings/risk-questions") +async def bot_risk_questions_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_risk_questions.html", ctx(request, user=user, active_page="bot-risk-questions", title="Вопросы риск-инжиниринга")) + + +@router.get("/service/bot-settings/storage") +async def bot_storage_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_storage.html", ctx(request, user=user, active_page="bot-storage", title="Яндекс.Диск")) + + +@router.get("/service/bot-settings/broadcasts") +async def bot_broadcasts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки")) + + +@router.get("/service/api/bot/risk-questions") +async def api_bot_risk_questions(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/risk-questions") + + +@router.post("/service/api/bot/risk-questions/create") +async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/create", body) + + +@router.post("/service/api/bot/risk-questions/edit") +async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/edit", body) + + +@router.post("/service/api/bot/risk-questions/toggle") +async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/toggle", body) + + +@router.post("/service/api/bot/risk-questions/delete") +async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/delete", body) + + +# ===================== BOT TICKETS ===================== + + +@router.get("/service/bot-settings/tickets") +async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки")) + + +@router.get("/service/api/bot/tickets") +async def api_bot_tickets(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), + status: str = Query(None), priority: str = Query(None)): + params = "" + if status: params += f"status={status}&" + if priority: params += f"priority={priority}" + return await _proxy("GET", f"/api/bot/tickets?{params}") + + +@router.get("/service/api/bot/tickets/{ticket_id}") +async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", f"/api/bot/tickets/{ticket_id}") + + +@router.post("/service/api/bot/tickets/create") +async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/tickets/create", body) + + +@router.post("/service/api/bot/tickets/{ticket_id}/message") +async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/message", body) + + +@router.post("/service/api/bot/tickets/{ticket_id}/status") +async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/status", body) + + +@router.get("/service/api/bot/engineers") +async def api_bot_engineers(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/engineers") + + +# ===================== BOT STORAGE (Yandex Disk) ===================== + +async def _get_yandex_client(db: AsyncSession): + from app.yandex_disk import YandexDiskClient + result = await db.execute(text("SELECT value FROM bot_settings WHERE key = 'yandex_disk_token'")) + row = result.scalar_one_or_none() + if not row: + return None + return YandexDiskClient(row) + + +@router.get("/service/api/bot/storage/list") +async def api_bot_storage_list(path: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + items = await client.list_folder(path) + return JSONResponse(items) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/search") +async def api_bot_storage_search(query: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + items = await client.search(query) + return JSONResponse(items) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/public-link") +async def api_bot_storage_public_link(path: str = Query(""), expiry: int = Query(30), + db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + url = await client.get_public_url(path, expiry) + return JSONResponse({"url": url}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/download") +async def api_bot_storage_download(path: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + info = await client.get_file_info(path) + dl_url = info.get("public_url", "") + if not dl_url: + dl_info = await client._request("GET", "/resources/download", params={"path": path}) + dl_url = dl_info.get("href", "") + return JSONResponse({"url": dl_url}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +# ===================== HANDOFF CLOSE ===================== + + +@router.post("/service/api/bot/handoffs/{handoff_id}/close") +async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/close") \ No newline at end of file diff --git a/py_service/app/templates/_sidebar.html b/py_service/app/templates/_sidebar.html new file mode 100644 index 0000000..a0b5393 --- /dev/null +++ b/py_service/app/templates/_sidebar.html @@ -0,0 +1,129 @@ +

+ + + \ No newline at end of file diff --git a/py_service/app/templates/pages/bot_broadcasts.html b/py_service/app/templates/pages/bot_broadcasts.html new file mode 100644 index 0000000..9dab2e3 --- /dev/null +++ b/py_service/app/templates/pages/bot_broadcasts.html @@ -0,0 +1,241 @@ +{% extends "page.html" %} +{% block page_content %} + + +
+

Рассылки

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

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

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

cnG9tO*<|BDk>-O3bkdG(dUoZ zM?rO4+}Tdo)W8^9^|5yV4?|r5hlkKBDs?-w@w6$}Acy(9fdc)z0b`^-r2Bp=FlbtF zhR9hWq>ek~X}>$?H-szqy(5$|)M=DbwkJc(ar-KtTWq(NmofBKwd@;E%a%^xqvD9c zO_5U?a%8=+o$xrYNS~xjYUV+iwy=H=nd;`rnR7FIf)PITx3V~_CyrKnE)~hJf^*{@eGjC|W{5i^Zea%=`kTr8l zIr6Un<@cHus_QCpr;egqTPlP)f{wCOL@^Jq`#|p(^GB$dr=naI=cRP6t;KoTvb9iu zQlzF85fZwf(rdw&onYIu-{ z^k8*fP__~e6B$kwvDlc$TPfa!*jF5{%cE`l zT8B3R&aiLZ3=}#Inq2g3;68!dr|oN~`4$~@1Dsgoy?+@SeP5CsLEq4pja0Pe^xX+|V{qOf;l z?AaRn2*Nb*7r1@mB?x03YP#DDjWspig#fZTY8F5erJq5iAMH*7NM#ATPnHi&>?IL4 zSs_yb7Hd&ndJuLMPhfa)fynb9X?cB+8YoT*N}zF4eTKg4;!XTSIs!&qN+$@B`BcK> zU3{;bu}Q^`sQ0MQS&&s73GqHylDcOC5ZSHvb`L1B&j{J%aAkC8WIdh$K)99aB7lxA zdRDNRhuTo!NkHUkGKlsHsiRFxa7%??D!GlbF<6QYIrk{Umne8jbz)=sA$lZ7CsV_< zewOdQRCjIse&h3vKW%)n@t4~-HvWA3r?-E$@yY(m#^?C`+wGsA{nOjO!1Es~8=r1` za{K+t#%JLFD{*ancKc@#Olo}}Mv5^+ibyZ=h`v(D0&;_Znm5@X=u}6^4FVLBDIaKi z(7<7g(+JXibX>(scx0!P7Y7x{x`rG;JJtBm z&!D570LM7%IvwAvfJx+IsU=1E;G| zp2YCnIUIp3I)@G)sTUUcwK(VqswmT46vMONMCL*2Wm;Z~_V%9JjIwaSiRU zd#GZLzT8LWv6MV)J1W|Apxi}TwsFpBzt?rVyzVU9Z4Ml9`{$4swo=XiuP4$O8Hv8e_gV6n-ltO zi~h=bS)V9Q=Fnp7%*#=$WigA+Na8U*#1h`>c{)FHTML51SY)CSrWbPGIA<3SD$dCx oHsMj@Jwzz6%P~hSA+nCf>VKJJm1)pIqK*GbYDu%SqGss-1G5_di~s-t literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/settings_cache.cpython-314.pyc b/max_bot/app/__pycache__/settings_cache.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f884a77589e1c6c3fa0a9453be40d8ca3cd55cee GIT binary patch literal 11361 zcmd5iZBScRcK3ZqdJ+O5@eMX$WMs>BfB;Ux#3T-O3>bqVJ93l7R%G#P%NCOI-Xk$| zKbTE+inF`p(A`;J+8N_E)7YK}H!zryW8nZ{=u>{j`vs3 zxq9#E0}||f%=FH@b3e{`_nmw0x#ymH-rZYPY9o;T?kVTRA36y69ai+jYlZbQP?#lW zh@TlE3g+w(Gsr4z6PCFlZje`aTIPpLgJ#7{%cddApr8nYR>eAKQ*47JN=Xx`B0GrR zTtobpZZ_MyV)rv{;uSu@>Zq4dg|UPZiN``wD4S(5Du$JtjA}n5PshS1L|F!$msf31 z#FZ1GqC{fnY57Spq)bVoS^@2TQBK5TvUpOQOhiM9sFpzE02I#0rRi|?vsq}2uoAcl z!5B{yh4B-GJtHVwHHnjUqVVvT;4!xm#d3xyf}c^Wr)>e!#rWA85*7R$!18XbU!~-< z&EHC~JjI$28)Xc<9q=BC2RXpomLhy9;CE5HmEvth_%iL=B@}B%tkJha!~(3$b}TyR>&JD7LcT-3kp?MRC;yxJnIILvghQxGD`-M{)H9xM~g8 zKylj&a5WlkJH<5?;A%Bo6U8+b;OaEo4vK3jz|~WEyAh)__;)JX0z}#F7eFs}tEHNn z$pfMA1u?RZAtVeB!3PuSF$d3jGk`~#SrQ;;*q)aOnSj=9k1SC3i`+sc8+t(l=?g4(%k2+~~;+g_j5s))O#-7SaUEW?aV+pHa#cb`V`b6YQyG zUw&U2(LQQ~vXubw>B}YsNbxM6in`)*(<=dmv=kh|$^gWcr=%w-8`)zt#F4w=T=U=5vVU^bE zUpmzs)3*q)CvIXPN7#{FQ{Xh=dX8Y78Tsp?`qBLQ$XY>dbUzniyzJ;nZ=K33kx3CO zJZu4i{q zN|Xddn_j#if&*m{W1+KAaY8LQmvyOvNN5deHCaNVs@fIJx1!imqo##~G~ zT{VZoO5_Dm72?u_Ct2Q|wn5zS0k@PzJFbqh1|Gp~96zCPX3ovzuR zaqgcpuXG=HbM%eTbob#*_h71f@J{z|y6RY}d-%qQh4b_07v*cyOVhWjcSB36>ew9j z16yG~nU({omIMEIAk%j|)ptDk)YGZHr_(Kgbj_)Z^AvR7``FJK-)c8}qv@Kl zRL|(GiffJ~$Bl~HwfmqpXHV9Q89m(bKALVhn6By1IQxN(p|SsHe6KM*^h{>xbZY4I zouOd5?zz-Z@Sok8`aP-oJvRn2`wph|9lTwC5L#1p&jCAST4x{FT|e$i+nX}>)|9>V zwM!XKPs-Et)59sx<7xY$jD0v|AO5$#%&}1FSSW2jyUww-w!4IN+2+ivX40_ZTH8`v zx_(clzAaS`g%0T2@o_`re+jj}cN5nm_tdImzjdCvr`Gy@>)d}&tr`1`v;Ur2_uLu} zecY4(0_OX>hlJrrn72#Z2-}$9D*o;EolyRHMbmIC|MSj6R;aww#|&@h-+2@fzi6%+ zZsvcn-wWkmHZsGl{4blZ{avnVcsKuUEq3%S&_Be#OX+{r+%(+I|LUZT zLbyg?t-v8D2FE2%RwOSgp~_3tz010jbe%|Ot|jz1(7MWK_Nn8?nD(s^0Q2Eh%&rg6 z?2QWAD1q-q!iEb16*MeR7uI{AfL`xruAeXe4WPqx%B7$EU>wZoO;$qxL*>qk%1ut% z3uQVi30)`|-}iVtS;qU2&RMv);Vmx`B7d{wV~6YN%$1p|-@fwg#cKS1Dy=3YMkH~Z3M?Q`5J%UK&f zsHlEb{!!1|*ry+q*Z$Xnv4;6<5?rjSLaP&*8 z(a+KrJ{pnlLtP%hff*~3H+8KM083q~nBA$xf~uHmk`vL00+}L35?)i0K1l;Ws4|yj~-Ww-g>rXp6 zulFy87A*6YpIEQ=ryQM0q4VBtM<;br-sWo$HSq;Hh1M7?+j=vA&$Ig&GXMigZ9TFF zrk%$C!PbW7AW-KH^^B<*&3N+?PqUm&1S*=48+Zh|6TyLAx0Ptuk=vrA&oS6UY5-X2 z1W?c^y2;S_kgC0nsR*V{Ggej zlJh{}^CU-iOs1WMm>Twljw|%;Z@jJS=rrJ@K`bn@j z{+N#>z1x0H~!d^xZO!9Vy3-Wp~=qo)p?QO8INk*5k-Zp0@5p$?m+K&2oGI6-{4_ zwo)M$YpW9_gvVW7g0kAQQB?~FMu4XRJsq7NO*`C4A#a`OdivlJ1iO9|KCrcRqH)NV zVJph8bxEPw*C0dWO2VVeh816iGRy@R)NLCDn8TudE|A{X{8-wtGb!xM@%+W5cb_Yp zYC)dmp%?8{}Dv z@-zt5hK=GhkSF2&yfv+#$u8aKOgq|=LR)SuU%O17L00l)+K5)Nb#r8v5bMotO3=8 zoosBoWluW}CWV8!QGRi0dl?BOx%K)#jocXWc9?dp5dcdQG|O=UR5Tfu3o_7tVVU5BMSN8`dcEW~pRpR_ z6h4E-a>j_VB&?^t=QDox=)Dj)WA(8;U^jE8JN=L!u=c?IkUfRbpnwqyfIt9nlnn%I zCS8~TO~VrWb~5sY9wzWwCERgFRRIInGw}0rru4{^6i;qi9e)$#DY~&r=wgE5q&TaU}A88l4ZN@N3!9d6GASb$t74?8a}eG__< zz6D^0KhV+f&EEQ?9y`qpJhTWjv!(OrV zRJ&?kty$z3Pb?l;u1GpslR|4wRJuSO+?A9r!uPk{NT!fgW6A_m^%?=N)J?M-A3#Nu zF=hV^)maEpC2&eKWPY|;P&(fsqMJ_s3tD;8_t=~x1@{D4Jy13QAuqVAz-$&G%SB|H zAc%HfZy073-hhyhms2f?3t&Zq4^wwduXTdP)C0HKFqs+dg`ly_23%D$eM45+#KZ7( zW+SeKb+Vybp;6RsbnLPi1^Slp+z-m*tbpG`rR7(jz4GkUGgr z`lX!gQh6$oz#pIDWGE8-97o%VlR=5pnvHWo5#Ir3HgT}yHzbHgLijN^4qcwwILLYc zUfn!&)=uyTQy=Iwo=LSbn^)$vG6OWcRt7i1Z^AO@r8ygFqdMq@CJEcKhcom~K|N`l z5RYuC>0C4(x{pXE{*jQM0PyLblkbxE?d2)HYKhP5o8lXOUjh{>l$!+1D;{znL)(>&Wi+ZnkZ$jASIQO)PR1vuqIho# zZc#ly6^fz*4>d?~ z1An13@H#B zKMNNCWM~FT<7!!0gqT7QhK}AT4UavZ{kdkxjuB9Ke+#q*|9a^cW zn;l-Mt)D%%Qr|E;vSKTp9r)N(ewT&IV(T0+J6Ex^+5x4zto}1mLqHXL6GZ60z%&F@ zSIa0u|Jk7-pt@?;x@tChY1Vpy{){%gX9z1AZEk}u8yhna9R^~j(YDiQYc<+hjkb29 zt=(wrH`@BOHaOvEZBSZmp^SrLp|wG2)kfQ31Jl}|v|6bRu*>ML>+bWY+Ak5Rs&qcQ zr4k0mRI@Ujfa|UjxFzs!O@2Naio#95$!U7UL#@s=w815ewh-Qzjl?B0QafQMXakiN zqq3IKD{95L-he9F5*gJU96E;iH7WU!G<-<5 Xe@NOtB7rms+%-=#OxHR=NIUsIK^{JU literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/settings_cache.cpython-38.pyc b/max_bot/app/__pycache__/settings_cache.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ed8b774cc54b883e27e3d643d49b067b3153aba GIT binary patch literal 4949 zcmbVQOLH5?5uTY{JPCjgQ4~eJX!;37qLhad$6*xNvK-s7>BOXMc`K={6=p<%TwtNu zr6r=k6IHpUQaMJc0*}1rbioB?u?d|QE{-(R9dj_AE z%SDFk=D#Z2=PQi;n_h-52QN2q$6uh4O!AO<^b{d)2v2aLozQ8xo@;40%rvrI*3y|U z*T{SMM!_pIie9lX1Dvt53>YC!aQL>?uq~6@J^!qAEcC|96!2e9et*9BRP1R_HL88nQn72S| zMS8DBdWNqoUT)%!C1*@Ak4xr>&sm*$j&fB-@-?EfmKK)IS=w1+Tg=O=0%Y7ZCNo}9 zO@YpW&UqzORu!3l!ev1gAG-(Kn^t9cS;XUIA0n(jVEc~bQaolIr<*%u-TWbYj~(z1^PQe-;edCGNnv|RIy>TUxSxAsztVBP zAsv}X*?qSsSsp=?os6s$qM6>FNzrs?lj-fbVN}QSK(A9|2_FFur{vaaWZM2k zhIDan;Eos2)M2haRQ7p?Z^KO6{4e5xyTOiFRTyVS?U~HiLAa}qgb}OPjiaN7dIr60 z^WEv7)w&j^t2Iu2z1rF{#Wh*4CAFy0x^afokh8K(KySM`5+JKu-P0<*zq%GiwIGad ztRDB=d_L^?KI~E-b_&{pI^t%k7i~W9>r;KSDQ=pwCR9tfhmd_wZF-T$d!CL9|F4ec zJHn!_BAfP?4Nb}`58e{>D0!~_z<` zo%r@ummyNk7^fD-dK$EGn$bg(MFi}IiC2QAL7qsRWh0GjV{*MnAx^xEhKW31;I5eG zi((n~JYTT1%a{IBbSk2K`ou^xtm^1lbT>Jv?gh|G`cYw;HJW_?BAQmoE!;6#3#o*4 zu|_)LP+)cN^&??zXPhmS969;*rqYjo*l)8Ax5;5<^pc3 z{RW7>_5>*oNki;A?PB6|oCE$P7P89{B)5BFr>NiJjD8)FWnbaT9i+MqreDPn)vU#< z35s#zL>s5xOib|$THUv`j|iWKUscgNO7c6h53R zmIi__E~RIS>4>L}9*x)3vsjL6=-!XnvGqh)Apzr^g-{B^MmOH*B6;0(({omuTxr$nH%>rRA~pSD@WBw8rAah>vF~LUo8wQi%WN+a z1}Nf>hhSWqgh6S(4<&vN`T{2oCM9pZw3vuOUwT0;nRP7 z`}pZM-~Q?O=;o~NM*7|vf;hIqCWS|El2S@qHt~D|0tRCt+voPlu|3DOyz5XZw-v0? z5nZ?A9P)klOV+%IDwaV7`>0>Rx-R7K?JL$4cW7tIy4enr|9+;!x;g1eoXBKO=4D|k zgYmLSzLV(|4pAERbDdl#yOYy@ltpa)S+^*sWC=2POXGY7y3m3A_LR#Uw!@>pcM3Zw zGXk{}4xV05Pa}ESEVZMiYL{>C>ZqlzeG3(?l_9oj%4c(6R!b&N=N!^v+}2*wKCTn`9qLuM{ivqwsaapb zyQadRX(Qnk>v8l@sU2X@*NEYg%4#vqrnCa}C&bPrZ5tAv!02|2cP+cumr5Wo8V8uh z6J8k^^%(W<(JiJ_O&hN~ub|LZ@SEW!Y{N?6xM)kfJr>p@V~m8$NcyAH2Io-7V2xo{ zPqc$tFAz5TuQ-Hv1lsK1a5TY*MpLHKA5!xXHNQdAcHh2!{c77mi?T>ILEW{r)& zR4_h68~(>rguiqXV)j(Pgq_X_OJKAEbghssZbPQZ@lQv}ST=dzn%?(u==B?s+zl1z zlJ7s*4MJL{zMndr3Xhrg{h-;5l7RjsiSfzquWj0;eY5^0HB=@{=3W>Di9Sd4R1F8_ zxS^8!k<^s5^h?xGjM)8#zDyKZBMqLnKpFRL6ovZd#H4+V{slEu>osjQ^{=S8NzL!5 z`HY(5VrVJ5tEIrE1#hch(Yl{(R>H1a@R>!y7aZI<+*!PXp2JN~;?r~Fo)uivh}E*D zqnXJjdoBD4q-hg@Q^bQX2=SLfW6vH^yoF=NDo#MF0sV_qkE{v2SsY7OVQ>}3y@x&2 g*keq3Vj=vE>0^7VjCIA2c literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/text_renderer.cpython-314.pyc b/max_bot/app/__pycache__/text_renderer.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c798ed033441c85e2df896d3d9fccf5d6b68290 GIT binary patch literal 2657 zcmb_eO-vg{6rSC+*ER+W1WZCwLh6E8kpzO=G>OQM5*igD8oEtascZ0=o2TnSFp9D|Xx(tGYCXvjIAe)Z-2=@hV^GA^^v?Dpt zj?5-okb@wLOb*BqIRsjmY9?m~WiElFx_5NLFC~tIuNCY}KBt>04rb_&68J~d=hgr) z2PtThKl438X_z~LzT$3kGN;0dOEO=T`+3-1c*nyE3EM`tVCuF(L~PhIIQN1hf@Ndy zD&ETDtZrM>-^b+4@HzEcJ(o7-hBMZ*YGrIg(~YT&mCqS!R-e;u=519k6ja-ovo&mh zB{uNM!aY*2X?iY~x50^~;dn1i-ah-=xT>{)7q4zC2JZNVyH*QElb5d5O> z+iHXZIp{hn@CrBX2ZLI8l=Q|XboC@cvv5tERQPc}J-!)?GE#)&wK`X;sq~nPlt9w+ zlcYfdD)6Ha^$6-js_4!jG#>V)>Ua#2uke+xf=Uohm4NTXe>{;`dj@&OiDaR&6F=JR z`d4T&1B9C=+(I#;&iAUbeUiFBkQ5|ANK)1!A#6a3QwEN}!3d8H61tVirFGMkLYN+( zh|{w=p0X$gtQpfL!nARZ1n=tRj6ovU$m*F~CO1WdyE?XL9(s;>LJ0F5p}`}`3VRPsut?F zhc?&D<$x$?EDB0`crNU1&;LhzVW+)W$tTlj_6-z-vP7wGHzesM18ADiwM4 zRPm#o>wp>4&R0#2?p(fY+Xd_5x&D5C4~fUEa6@Q5GdCf^oglLa_jn*H0Q3ya%;cs4 zXpoL$V8Eh%2?PhNM;jiVesFrVrN7j+5-3N{JHwT!!P4g|$#V2k%~Y~vtXwZgFFV6) z(U>z@&57Sn>S0%wlkeE}`){tYP|cqlsF8y;vRIY-MTl;Q0gYXngbKHzrD~^qVUPrA z0h*+N4I0Gfj*;3Y9^-I&>nyv%Hmaw11oq6@pu@|r+3<_NhV`z*h5)ncXNTPJ>c}S5 z-Ox~o`c#4I1qjwY;mTFuS`&tUfj3{V7ZqW=*1LBKnXhSvAtkVL za7NevHnY}xXmRY(*ivF?rrdhM8C#1devkM47VlfWQfe#5KXb0ViN+od-5+`yTRvQl ze##1ZH+}V>w0yoCJzFtYJ$R~gu^heP48smy3y=aV4<9f|bX${X-z&f~h-qJ7j~r`4 z>{c%js}r|Zg@>~F^o(g-!1SLP?I_k!U>gF*aj#M24|Mpw2>*26x5A^a-3T?ts*-K+ E7qS`m`2YX_ literal 0 HcmV?d00001 diff --git a/max_bot/app/__pycache__/text_renderer.cpython-38.pyc b/max_bot/app/__pycache__/text_renderer.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bff0513b99d61e17711840ff5dcd18883fe344b4 GIT binary patch literal 1315 zcmZ8hOHUgy5cXqVfjm%4fl{Gzsl*Bi3P)5?r1n;cO09Ycn?_6QLda&b^*TJl?y39? z4sh(B=)Hfz*Pi+pxX`h8lM=l0c)s!2p7}i9FSXhXg0}a&e)7vg=#K^un+ISIdin_j zLkvf#sYepwK4}t+IOUYlYr-sMUs+AdU6A;ZUL zIXGB5_!_iYKt?NAlRVl1=(pAbe?)?<(aQ8ZWEjc8f0vij)5lUK>D!&n&G8;S5wj2% zFgiT+v3Lf$p`{Q{^;9DXMatVzH|_x)=duCDRM#rPb@Pcu(1orP-!VHVH#r`}yuCG6 zww0~9k5tVJZ8+?NcS^!ELE}~dWkT37fC*t32(u@M0CF;VLl`v$gM}lkGDHfx6=y`qv8%z9YU&09}#_jRa_;|%_vsD>RX0^M*?1$DHKOuB=Cd;0Z*a- tXXXhMC7@S72tl7AObK~!`U5tM_^y5sCIAmt>D 0 + attachment_type = "" + attachment_path = "" + + if has_attachment: + att = attachments[0] + attachment_type = att.get("type", "") + if attachment_type == "contact": + payload = att.get("payload", {}) + vcf_info = payload.get("vcf_info", "") + import re + phone_match = re.search(r'TEL[^:]*:(\+?\d+)', vcf_info) + name_match = re.search(r'FN:(.+)', vcf_info) + if phone_match: + user_data["phone"] = phone_match.group(1) + if name_match: + user_data["first_name"] = name_match.group(1).strip() + await _handle_contact_shared(bot, user_data, db, max_api) + return + attachment_path = att.get("url", "") + + user_data["has_attachment"] = has_attachment + user_data["attachment_type"] = attachment_type + user_data["attachment_path"] = attachment_path + + state = FSM.get_state(max_user_id) + + if state == BotState.IDLE: + if text.lower() in ("/start", "/help"): + if not SettingsCache.is_work_hours(): + from app.handlers.out_of_hours import handle_out_of_hours + await handle_out_of_hours(bot, user_data, db, max_api) + return + from app.handlers.greeting import handle_start + await handle_start(bot, user_data, db, max_api) + return + + if not SettingsCache.is_work_hours(): + from app.handlers.out_of_hours import handle_out_of_hours + await handle_out_of_hours(bot, user_data, db, max_api) + return + + emotion = await detect_emotion(text) + if emotion == "negative": + tmpl = await get_template_rendered(db, "emotion_escalate") + await max_api.send_message(max_user_id, tmpl) + from app.handlers.handoff import handle_handoff_request + await handle_handoff_request(bot, user_data, db, max_api) + from app.max_notifications import notify_handoff_escalation + user = await get_or_create_user(db, max_user_id) + conv = None + try: + from app.conversation import get_open_conversation + conv = await get_open_conversation(db, user.id) + except Exception: + pass + await notify_handoff_escalation( + db, max_api, user, + conv.id if conv else 0, text + ) + return + + kb_entry = await find_in_knowledge_base(db, text) + if kb_entry: + await max_api.send_message(max_user_id, kb_entry.answer, format="markdown") + return + + from app.conversation import get_or_create_user + user = await get_or_create_user(db, max_user_id) + if not user.consent_given: + from app.handlers.greeting import handle_start + await handle_start(bot, user_data, db, max_api) + return + + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = "new_inquiry" + await handle_main_menu(bot, user_data, db, max_api) + return + + if state == BotState.NAME_REQUEST: + if not text: + await max_api.send_message(max_user_id, "Как к вам обращаться?") + return + user = await get_or_create_user(db, max_user_id) + user.first_name = text + await db.flush() + from app.handlers.consent import handle_consent_request + await handle_consent_request(bot, user_data, db, max_api) + return + + if state == BotState.CONSENT_REQUEST: + if text.lower() in ("да", "даю согласие", "согласен", "yes"): + from app.handlers.consent import handle_consent_given + await handle_consent_given(bot, user_data, db, max_api) + elif text.lower() in ("нет", "не даю", "не согласен", "no"): + from app.handlers.consent import handle_consent_refused + await handle_consent_refused(bot, user_data, db, max_api) + else: + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = text.lower() + await handle_main_menu(bot, user_data, db, max_api) + return + + if state == BotState.CONTACT_REQUEST: + from app.handlers.contact import handle_contact_text + await handle_contact_text(bot, user_data, db, max_api) + return + + if state == BotState.INQUIRY_REQUEST: + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) + return + + if state == BotState.CATEGORY_SELECT: + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) + return + + if state == BotState.OUT_OF_HOURS: + from app.handlers.out_of_hours import handle_out_of_hours_callback + await handle_out_of_hours_callback(bot, user_data, db, max_api, text.lower()) + return + + _feature_handlers = { + BotState.FEATURE_RISK_SCORE: ("app.handlers.features.risk_score", "handle_risk_score"), + BotState.FEATURE_SLA_STATUS: ("app.handlers.features.sla_status", "handle_sla_status"), + BotState.FEATURE_PHOTO_REPORT: ("app.handlers.features.photo_report", "handle_photo_report"), + BotState.FEATURE_APPOINTMENT: ("app.handlers.features.appointment", "handle_appointment"), + BotState.FEATURE_COMMERCIAL: ("app.handlers.features.commercial", "handle_commercial"), + BotState.FEATURE_MY_OBJECTS: ("app.handlers.features.my_objects", "handle_my_objects"), + BotState.FEATURE_REMINDERS: ("app.handlers.features.reminders", "handle_reminders"), + BotState.FEATURE_QUALITY_RATE: ("app.handlers.features.quality_rate", "handle_quality_rate"), + BotState.FEATURE_NOTIFICATIONS: ("app.handlers.features.notifications", "handle_notifications"), + BotState.FEATURE_BROADCAST: ("app.handlers.features.broadcasts", "handle_broadcast_send"), + BotState.SLA_TICKET_CREATE: ("app.handlers.features.sla_ticket", "handle_sla_ticket"), + } + if state in _feature_handlers: + mod_path, func_name = _feature_handlers[state] + import importlib + mod = importlib.import_module(mod_path) + func = getattr(mod, func_name) + await func(bot, user_data, db, max_api) + return + + if state == BotState.KB_SEARCH: + kb_entry = await find_in_knowledge_base(db, text) + if kb_entry: + await max_api.send_message(max_user_id, kb_entry.answer, format="markdown") + FSM.transition(max_user_id, "found") + else: + await max_api.send_message(max_user_id, + "К сожалению, не нашёл ответа на ваш вопрос. " + "Опишите подробнее или нажмите «Оставить заявку» для создания обращения.") + FSM.transition(max_user_id, "not_found") + from app.keyboards.inline import build_main_menu_buttons + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + return + + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = text.lower() + await handle_main_menu(bot, user_data, db, max_api) + + +async def _handle_callback(bot, user_data, update, db, max_api): + max_user_id = user_data["user_id"] + callback = update.get("callback", {}) + payload = callback.get("payload", "") + callback_id = callback.get("id", "") + + user_data["payload"] = payload + + if payload == "cancel": + FSM.reset(max_user_id) + from app.keyboards.inline import build_main_menu_buttons + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + await max_api.answer_callback(callback_id) + return + + if payload.startswith("rate_"): + from app.handlers.features.quality_rate import handle_quality_rate + await handle_quality_rate(bot, user_data, db, max_api) + await max_api.answer_callback(callback_id) + return + + from app.handlers.main_menu import handle_main_menu + await handle_main_menu(bot, user_data, db, max_api) + await max_api.answer_callback(callback_id) + + +async def _handle_contact_shared(bot, user_data, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + if user_data.get("phone"): + user.phone = user_data["phone"] + if user_data.get("first_name"): + user.first_name = user_data["first_name"] + await db.flush() + + state = FSM.get_state(max_user_id) + if state == BotState.CONTACT_REQUEST: + from app.handlers.contact import handle_contact_provided + await handle_contact_provided(bot, user_data, db, max_api) + else: + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = "new_inquiry" + await handle_main_menu(bot, user_data, db, max_api) diff --git a/max_bot/app/config.py b/max_bot/app/config.py new file mode 100644 index 0000000..e4a4130 --- /dev/null +++ b/max_bot/app/config.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone" + APP_ENV: str = "development" + LOG_LEVEL: str = "debug" + WEBHOOK_PATH: str = "/webhook" + WEBHOOK_SECRET: str = "change-me-to-random-secret" + UPLOADS_DIR: str = str(Path(__file__).parent.parent / "uploads" / "bot") + YANDEX_DISK_TOKEN: str = "" + YANDEX_DISK_ROOT: str = "/AegisOne_Service" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/max_bot/app/conversation.py b/max_bot/app/conversation.py new file mode 100644 index 0000000..739cf7b --- /dev/null +++ b/max_bot/app/conversation.py @@ -0,0 +1,142 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import ( + BotUser, BotConversation, BotMessage, BotConsentLog, + BotCategory, BotSetting, BotFeature, BotResponseTemplate, BotKnowledgeBase +) +from app.settings_cache import SettingsCache +from app.text_renderer import render_template + + +async def get_or_create_user(db: AsyncSession, max_user_id: int, + first_name: str = "", last_name: str = "", + username: str = "") -> BotUser: + result = await db.execute(select(BotUser).where(BotUser.max_user_id == max_user_id)) + user = result.scalar_one_or_none() + if user: + user.last_active_at = datetime.now() + if first_name and not user.first_name: + user.first_name = first_name + if last_name and not user.last_name: + user.last_name = last_name + if username and not user.username: + user.username = username + return user + user = BotUser( + max_user_id=max_user_id, first_name=first_name, + last_name=last_name, username=username + ) + db.add(user) + await db.flush() + return user + + +async def get_open_conversation(db: AsyncSession, user_id: int) -> Optional[BotConversation]: + result = await db.execute( + select(BotConversation).where( + BotConversation.user_id == user_id, + BotConversation.status == "open" + ).order_by(BotConversation.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() + + +async def create_conversation(db: AsyncSession, user_id: int) -> BotConversation: + conv = BotConversation(user_id=user_id, status="open") + db.add(conv) + await db.flush() + return conv + + +async def add_message(db: AsyncSession, conversation_id: int, direction: str, + text: str = "", has_attachment: bool = False, + attachment_type: str = "", attachment_path: str = "", + max_message_id: int = None) -> BotMessage: + msg = BotMessage( + conversation_id=conversation_id, direction=direction, text=text, + has_attachment=has_attachment, attachment_type=attachment_type, + attachment_path=attachment_path, max_message_id=max_message_id + ) + db.add(msg) + await db.flush() + return msg + + +async def log_consent(db: AsyncSession, user_id: int, action: str, method: str = "", ip: str = ""): + log = BotConsentLog(user_id=user_id, action=action, method=method, ip_address=ip) + db.add(log) + await db.flush() + + +async def get_template(db: AsyncSession, key: str) -> str: + await SettingsCache.refresh(db) + return SettingsCache.get_template(key) + + +async def get_template_rendered(db: AsyncSession, key: str, **kwargs) -> str: + tmpl = await get_template(db, key) + return render_template(tmpl, **kwargs) + + +async def find_in_knowledge_base(db: AsyncSession, query: str) -> Optional[BotKnowledgeBase]: + query_lower = query.lower() + result = await db.execute( + select(BotKnowledgeBase).where(BotKnowledgeBase.active == True) + ) + entries = result.scalars().all() + for entry in entries: + if query_lower in entry.question.lower(): + return entry + keywords = entry.keywords or [] + for kw in keywords: + if kw.lower() in query_lower: + return entry + return None + + +async def auto_categorize_text(db: AsyncSession, text: str) -> Optional[BotCategory]: + text_lower = text.lower() + categories = SettingsCache.get_categories() + best_match = None + best_score = 0 + for cat in categories: + keywords = cat.keywords or [] + score = sum(1 for kw in keywords if kw.lower() in text_lower) + if score > best_score: + best_score = score + best_match = cat + return best_match if best_score > 0 else None + + +async def close_conversation(db: AsyncSession, conversation_id: int): + conv = await db.execute( + select(BotConversation).where(BotConversation.id == conversation_id) + ) + conv = conv.scalar_one_or_none() + if conv: + conv.status = "closed" + conv.closed_at = datetime.now() + await db.flush() + + +async def update_conversation_status(db: AsyncSession, conversation_id: int, status: str): + conv = await db.execute( + select(BotConversation).where(BotConversation.id == conversation_id) + ) + conv = conv.scalar_one_or_none() + if conv: + conv.status = status + await db.flush() + + +async def detect_negative_emotion(text: str) -> bool: + negative_words = [ + "ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо", + "бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь", + "раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд", + "верните деньги", "мошенники", "обман" + ] + text_lower = text.lower() + return any(word in text_lower for word in negative_words) diff --git a/max_bot/app/database.py b/max_bot/app/database.py new file mode 100644 index 0000000..7fe217b --- /dev/null +++ b/max_bot/app/database.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from app.config import get_settings + +settings = get_settings() + +engine_kwargs = {"echo": False} +if "postgresql" in settings.DATABASE_URL: + engine_kwargs["pool_size"] = 10 + engine_kwargs["max_overflow"] = 20 +engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs) + +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/max_bot/app/file_storage.py b/max_bot/app/file_storage.py new file mode 100644 index 0000000..729649c --- /dev/null +++ b/max_bot/app/file_storage.py @@ -0,0 +1,77 @@ +import uuid +import json +import logging +from pathlib import Path +from datetime import datetime + +ALLOWED_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".txt", ".jpg", ".jpeg", ".png", ".gif"} +logger = logging.getLogger(__name__) + + +class FileStorage: + def __init__(self, base_dir: str = "uploads/bot"): + self.base_dir = Path(base_dir) + self.base_dir.mkdir(parents=True, exist_ok=True) + + async def save_file(self, file_bytes: bytes, filename: str, conversation_id: int, + file_type: str = "document", db=None) -> str: + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + ext = ".bin" + safe_name = f"{uuid.uuid4().hex}{ext}" + conv_dir = self.base_dir / str(conversation_id) + conv_dir.mkdir(parents=True, exist_ok=True) + file_path = conv_dir / safe_name + file_path.write_bytes(file_bytes) + + meta = { + "type": "conversation", + "id": conversation_id, + "filename": filename, + "file_type": file_type, + "timestamp": datetime.now().isoformat(), + "local_path": str(file_path), + } + meta_path = conv_dir / "metadata.json" + if meta_path.exists(): + try: + existing = json.loads(meta_path.read_text()) + if "attachments" not in existing: + existing["attachments"] = [] + existing["attachments"].append({"filename": safe_name, "original": filename}) + meta_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2)) + except Exception: + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + else: + meta["attachments"] = [{"filename": safe_name, "original": filename}] + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + + # Queue for Yandex Disk sync (service portal picks this up) + if db is not None: + try: + from app.models.models import FileSyncQueue + remote_path = f"bot/conversations/{conversation_id}/{safe_name}" + queue = FileSyncQueue( + local_path=str(file_path), + remote_path=remote_path, + entity_type="conversation", + entity_id=conversation_id, + status="pending", + ) + db.add(queue) + await db.flush() + except Exception as e: + logger.error(f"Failed to queue file sync: {e}") + + return str(file_path) + + def get_file_type(self, filename: str) -> str: + ext = Path(filename).suffix.lower() + if ext in {".jpg", ".jpeg", ".png", ".gif"}: + return "image" + if ext in {".pdf", ".docx", ".xlsx", ".txt"}: + return "document" + return "other" + + +storage = FileStorage() diff --git a/max_bot/app/fsm.py b/max_bot/app/fsm.py new file mode 100644 index 0000000..51d8b7b --- /dev/null +++ b/max_bot/app/fsm.py @@ -0,0 +1,209 @@ +from enum import Enum + + +class BotState(str, Enum): + IDLE = "idle" + NAME_REQUEST = "name_request" + CONSENT_REQUEST = "consent_request" + CONTACT_REQUEST = "contact_request" + INQUIRY_REQUEST = "inquiry_request" + CATEGORY_SELECT = "category_select" + CONFIRMATION = "confirmation" + HANDOFF_REQUEST = "handoff_request" + FEATURE_RISK_SCORE = "feature_risk_score" + FEATURE_SLA_STATUS = "feature_sla_status" + FEATURE_PHOTO_REPORT = "feature_photo_report" + FEATURE_APPOINTMENT = "feature_appointment" + FEATURE_QUALITY_RATE = "feature_quality_rate" + FEATURE_COMMERCIAL = "feature_commercial" + FEATURE_MY_OBJECTS = "feature_my_objects" + FEATURE_REMINDERS = "feature_reminders" + SLA_TICKET_CREATE = "sla_ticket_create" + KB_SEARCH = "kb_search" + FEATURE_NOTIFICATIONS = "feature_notifications" + FEATURE_BROADCAST = "feature_broadcast" + AWAITING_RATING = "awaiting_rating" + WAITING_1C_RESPONSE = "waiting_1c_response" + MAIN_MENU = "main_menu" + HANDOFF_CONFIRMATION = "handoff_confirmation" + OUT_OF_HOURS = "out_of_hours" + + +STATE_TRANSITIONS = { + BotState.IDLE: { + "start": BotState.NAME_REQUEST, + "new_inquiry": BotState.NAME_REQUEST, + "handoff": BotState.HANDOFF_REQUEST, + "main_menu": BotState.MAIN_MENU, + "cancel": BotState.IDLE, + }, + BotState.NAME_REQUEST: { + "name_provided": BotState.CONSENT_REQUEST, + "cancel": BotState.IDLE, + }, + BotState.CONSENT_REQUEST: { + "consent_given": BotState.CONTACT_REQUEST, + "consent_refused": BotState.IDLE, + "cancel": BotState.IDLE, + }, + BotState.CONTACT_REQUEST: { + "contact_provided": BotState.INQUIRY_REQUEST, + "cancel": BotState.IDLE, + }, + BotState.INQUIRY_REQUEST: { + "inquiry_provided": BotState.CATEGORY_SELECT, + "cancel": BotState.IDLE, + }, + BotState.CATEGORY_SELECT: { + "category_selected": BotState.CONFIRMATION, + "cancel": BotState.IDLE, + }, + BotState.CONFIRMATION: { + "done": BotState.IDLE, + "edit_name": BotState.NAME_REQUEST, + "edit_contact": BotState.CONTACT_REQUEST, + "edit_inquiry": BotState.INQUIRY_REQUEST, + }, + BotState.HANDOFF_REQUEST: { + "confirmed": BotState.HANDOFF_CONFIRMATION, + "cancel": BotState.MAIN_MENU, + }, + BotState.HANDOFF_CONFIRMATION: { + "done": BotState.IDLE, + "cancel": BotState.MAIN_MENU, + }, + BotState.OUT_OF_HOURS: { + "leave_message": BotState.INQUIRY_REQUEST, + "callback": BotState.NAME_REQUEST, + "cancel": BotState.IDLE, + "main_menu": BotState.MAIN_MENU, + }, + BotState.MAIN_MENU: { + "new_inquiry": BotState.NAME_REQUEST, + "feature_risk_score": BotState.FEATURE_RISK_SCORE, + "feature_sla_status": BotState.FEATURE_SLA_STATUS, + "feature_photo_report": BotState.FEATURE_PHOTO_REPORT, + "feature_appointment": BotState.FEATURE_APPOINTMENT, + "feature_quality_rate": BotState.FEATURE_QUALITY_RATE, + "feature_commercial": BotState.FEATURE_COMMERCIAL, + "feature_my_objects": BotState.FEATURE_MY_OBJECTS, + "feature_reminders": BotState.FEATURE_REMINDERS, + "feature_notifications": BotState.FEATURE_NOTIFICATIONS, + "feature_broadcast": BotState.FEATURE_BROADCAST, + "sla_ticket_create": BotState.SLA_TICKET_CREATE, + "handoff": BotState.HANDOFF_REQUEST, + "kb_search": BotState.KB_SEARCH, + "cancel": BotState.IDLE, + }, + BotState.FEATURE_RISK_SCORE: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_SLA_STATUS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_PHOTO_REPORT: { + "done": BotState.MAIN_MENU, + "photo_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_APPOINTMENT: { + "done": BotState.MAIN_MENU, + "date_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_QUALITY_RATE: { + "done": BotState.MAIN_MENU, + "rating_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_COMMERCIAL: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_MY_OBJECTS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_REMINDERS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_NOTIFICATIONS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_BROADCAST: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.SLA_TICKET_CREATE: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.KB_SEARCH: { + "found": BotState.MAIN_MENU, + "not_found": BotState.INQUIRY_REQUEST, + "cancel": BotState.MAIN_MENU, + }, + BotState.AWAITING_RATING: { + "rating_provided": BotState.IDLE, + "skip": BotState.IDLE, + }, + BotState.WAITING_1C_RESPONSE: { + "received": BotState.MAIN_MENU, + "timeout": BotState.IDLE, + "cancel": BotState.MAIN_MENU, + }, +} + + +class FSM: + _states: dict = {} + _tmp: dict = {} + + @classmethod + def get_state(cls, user_id: int) -> BotState: + return cls._states.get(user_id, BotState.IDLE) + + @classmethod + def set_state(cls, user_id: int, state: BotState): + cls._states[user_id] = state + + @classmethod + def reset(cls, user_id: int): + cls._states.pop(user_id, None) + cls._tmp.pop(user_id, None) + + @classmethod + def set_temp(cls, user_id: int, data: dict): + cls._tmp[user_id] = data + + @classmethod + def get_temp(cls, user_id: int) -> dict: + return cls._tmp.get(user_id, {}) + + @classmethod + def clear_temp(cls, user_id: int): + cls._tmp.pop(user_id, None) + + @classmethod + def can_transition(cls, user_id: int, event: str) -> bool: + current = cls.get_state(user_id) + return event in STATE_TRANSITIONS.get(current, {}) + + @classmethod + def transition(cls, user_id: int, event: str) -> BotState: + current = cls.get_state(user_id) + transitions = STATE_TRANSITIONS.get(current, {}) + if event not in transitions: + return current + new_state = transitions[event] + cls.set_state(user_id, new_state) + return new_state + + @classmethod + def clear_all(cls): + cls._states.clear() + cls._tmp.clear() diff --git a/max_bot/app/handlers/__init__.py b/max_bot/app/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc b/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee094bc95c38ef285ba4df2744b4db22faf8d53b GIT binary patch literal 159 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 CzsC9i literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc b/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..072bca16b3907d671df6cb20f7c12f66cc0d2abe GIT binary patch literal 2264 zcmb7_OK;>v5P;o&+w)9F_Qfl_8H5tC+z>)Q6a)u?mdRdxv7G77j3<84?nySta|+k| z0}eA1m;D{w_yHXH%89>#1fr_#nanN-36ES;RbAa~cYRfU-0AolTp#|@ojwHZH#9ac z1B_4ME;A5Rqa@K%5)gvAp6IC&7^xYUsTEktXC!v&1WxJ(uCmRfG{`4fnWP;y_Jn?kGf%B;wOP@Nh}U1{c8 zvrxOXzJl}oQkz?qzR;IiZ4pfv3$xOfWW!dcx76phfLQ8QHuY|dX0{{jYi;gU#?*l) zI+aV|G;hrHY1OF`Mr3kYqWm1z~5;aEPVg$N_?dEvH zB2VJcRXF3x_j?l|it?j_gNTjeGSApPpB>=1h)2*hZtDn_=06pj|<+07{!dL#1%7Jiin`IVoAmG*D%(|^2=ar zHGs=Pw$mc$u3RY-lbmuaS9k*D+o13lP$-Nr5quW~^1|<+Sd(({+2IB${4Ne6Df}La z`zSDn{6!SF9sUxEmr)=VwRjaYx$_(c>J@OA5(QGw*KN`U4s5bVfCKp7A^a^Edxn7y zIM%J^S1_J!HR~K%f~XAZ*mxkrzyh_N%dokTVQT?IY&07SX4p%Dtp+p)G)HMJXs&RO zhsv(o%+0ESd9+2_iJN+JA4tG?9f)pKejQo0uFZKH8k7&zcvYKrX>X%JwUDNz`nW)I z>eti+Q1lo1Iivh_cvt=g3S>k&#RS?-_`ynfC_Ck>C~_`BmPT>1*?7*Ndg;L6Z{zHD zP`rzx;qiX`QK;8bb5~yg_)6hYVL3aUvXS6DoYe<0=qMl+RQyf!-cp6FitnI6zT^%x zhW%AfD)-|oi8ICrIKHM%TF};*fK~sSu=V}}OKq&C4c7{s)gJ$iy?^$g`b1!2iMy}q ztEp3+2uRd5o#=nH4J5LQ-ROOYJ0A3dZG0ivt@Q Lq;GV$x?6t(5J@Nr literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc b/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ab86d828a5fd99aa16665d7548e2a4017b15a09 GIT binary patch literal 1495 zcmZ`(%WoS+7~h$lefSZ_q-{b4LOx~{>Pqy0R3TacMdUz4>*m1L)w1yuRP>+x5A*vH{<@ z-)mccT88n5GG;#$jEgYRt01UBQP1$zjC&}?K1ND6duB|05?j8dZPK%2$9H1acagzL z)cO`tn>zQ2U!GbnErC^`sAYKN=diP}=AmM)#e|6Zo9WwOr^`HCENn6nBs}PF777*& zQ^t!^$|qPn=qVRxeabkaknFydh?W3nVZPgXAGRy0{D6ic>=1Rt^ee#(FExIHk$wR} zB8pBMJ46@~OR|So;Z5%uJC;NvL@_nTc*oW{(YhmTeqP$r3b1teJJJEolz2qO#$+Wj zge5Vxj(2jUxn)ZtT~&pz&EqU*6c&rasOQf| z{hPyx-+?#Z9I{mSZXEKDX|mmix7r&Ck3->AxB{#&p-5aklvk8#ZS;m|m#Zoi)f7q= z#4Jt2O~!3-`K1&}nyS)qRTzJMb?x%{>Xi?JE0->>Uus?TN`-kN5!_MhF}+bZad_y{k0Sh24}lrpY|9}&vueNpdy-sJH^t(8BATlfG4*i z%BVUleG!C#TvW$ZRK*0@NR1kFc0(!t$h298_0a=0ng|scq5urp{ zfV87^OY1J+`+;;u7PZGn+JP-y{*iQ#ZAUt37ku9U`tA(+B?%|P3FaO-!kQW_Lz<(q zB-9yC8K!zgW6oW;D?g=#YR0?gm)k2BUcc5(+h^L#*Dti`C+Bu1mN#o@{k2JCs(W`A zm#?+An`f@CT%6ABW_x?ZdyZh^;pZT-@3Lpv<7_{BlJ8}|-DXdiRXfWX|=B%(&7V=KFur_%z9Q?y4UW056 zI-dv4EAhH=D4y^alvq&Wgc7=xCqeTox`_g4jZW9E=sQmg&B}>bg2dv)e`u(~N`YUm^)n7;yXugYJ7$zK4LH=&#KUo7aT-x$X#P*(w<_$^?6D59jV z->W;)cT{X6jdkZoqk7XAkH C$e?2Y literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc b/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb85bb189d00d0b2a6d3b30524d5f3e397603f38 GIT binary patch literal 2489 zcmdT`-EY%Y6u;MTYRCB?3H^Z5Qc9taHnd$S5Oo2mrKHIyC`%#L+JNUaZflx2k?oEm zty0902HOMhL?&d+XlIXMJv_fM`|AD`8Vejm`DqA{CGx72-$368bB$yVPZ-VMG}~f>>1s`^MWjAh}%4` zxZy+wy1E`j4!uG3xZ@QO*EM5U0W+GQiL5k&{sQ{SIk?MK)I@HFW5{1`0uaj5aU3-x zv1*2Q9;13HnUJ-<2V+@-sd^8FUV~v0B+f<~*)|f7Gd+-dqzyu*oPxDEoU;sBIh1AN zRJ5{IMwDewW%4$@2_>pbT$DAn)v+1Hsh-L$-?7(jB1d*c9j3lInsIo0XV$jnsjgML zsWl_h-z*om?WmZO@J1R?!egS2QL(Bx8}*v=)$v1Y?wNwvG{E~iW{un4h^kyun0A*W{3CgDBI926GnOY-+2c8A-wn zEo)*Br(#^3i`n;8^5g$>3kDs+L!*W~WXLu{x*0Td8~Rn~0`&cREE4uPbjBbJDXDXl zlA>z7ETlypxFjW*6P6%|`6!TLg$;n^v{^+4Uhq*$H3U>=2qp4x4x1K~nPld)?86F4 zLY*}?5mqAS_}sb$m{cJtqFX0Zv+A@$0 zdFfPgTxSUT30X zM^KJ3r4mmRP1yw|;=VcsH1HCLu;kse5c%2Lnmb(b)L&`5+_>=hVyxil%6(LFZ@l8X z>|B^x>@K)Fa*@)y4HxGx%rA^CdJF6Px$v^5|$TuYH|$JpH-~t#2!M z+s`v4S548?_1M*QW7F-y{IQdHS69Ks7g;{f@=L7!qVp@~rJ3u2@ArSV|0(;yudHj? z(|S!=?EP`zsi&`K@5>D=xwaQvJ92|1&Qs+4k2(M1*li{s9nEw80ykEq$MW>p63u*h zASWDqHo8cJ@nnyEui0HHWJxfOXNNr3efig z+d;qIh(o>f{mntpAJpSekbba<*dHPsIzT_fexi3`BD)Q_$B+R-_8Ky1$OA+kg?jYD z*S7;Q*7zCWC&=GOxj5WpVeNm1Q9nkh7$u=s6M&ieYKJ7JBv~xi2t$@8)wEFnMpYOI z_ybZ1?U(bD4W={6*_3!d*$p=GaZ)#e_|t+heun(d(TT_C#4{9lj+)<8!B^hc`VU>N B73=^2 literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc b/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..212e20207c7674f764453c88aba22d2a6fdc0567 GIT binary patch literal 1188 zcmbVL%We}f6tz8`S2Ag%s4w_~NT^+~MF{Z{m7tr5jW;7Fc9Kcw5!nuHrHO=8mh=x; zBul=79pVdS%ZguMfw*=O)HROAx%c?`Ui;kR_nl4)!Fc?AZ}!ng=%;n=4-cGY@aYSf z1QIw$1!fpqo8+YMm{(9n3!nK8^YWkwS?Fwu1c4|}3na7(uFF+_xGv~1O}`e9(V2w**)e}=sA49L8r+sL+Q-J)eu zd$TrFZP!4thPZe=#lpHGwp6z2pAfM9`*E-XNmz(1$P&#sDm2EG3s6jR$60P|} zrdOGnrkC<&SaBs(WCLOidW8K(s?hrM{N>wM{SNbnl~J)ph#0c4;MXaiXRK|FEiIce z3B)KXr-oXb#nn#8YTuzoUKDPxbM zAHjrhgcJKCM|h8PNe@Sa;s8+kC-DM&g7+{b2Y{Slbqw(raEBfgIfeVV2bhlR3*1oI zwF~#(nq%7$>j-2AHeg4TKF!KJE9HjK&@PR2;plQ4HdJIIlLAuT6aJ z-WgKyVim-XK!O)gMS19B`vgKf@sK0 zxHl!3LP|8nC`q$+lJ>Nd*(TGM_O=t#pB8#aOx(b*F=_>;RMXT-GsY`QuBgV`R?CcP z$~u>GTvbd}t{5t}+H74}Ey+2(^rp%U#nkjtU2Up!_zm1M|e*RgjA$%C$oT%bjordimjkLM_)>e@gb%ZHiBdBs#9 zYnIn?Cd}S~&gT@LH z(~xctiQ?gCnhZSS7!XosS?3za^ugiApFInI^eP;Go>txGJbqI4WE(q#Oz!+bBTzRP zZb?m_9Vwpr z=m(&0fNld-fi41#B-|Q}c`d=Ncr8Bxh2B~(Sy`_DI16%7uW-W>PzmL0v7$Cp1S>&~ z=j2MM5NqZ5<2eG&3TlS@md>v=oH_sigAM=&cgEvb>m2LY3K#%pc}_J9Wk%&5RJpXg zIkos!U;Rn*;hvKQVQ1_kQuX|QPqcw4KNgQ1hLywyK?(n+KbPyvE{7EN+{8HF9rcrFp0(4ONrtg0#9X`T_e`S`9~F z3a*BlJ}G`!{OD>;3@?e{-^J0@P**L~zZ~kn>skrLYhrv!j6Y(+`{!?-|MbY0^R?*6 za&+Wpc5Jhs1QMGf@x8R^C4r-BEdHxBgzFAi`(Ai#ya#8z8BRv&7c|+-Zbv}+t_9da zfF@hnLa+_x9-3@p3%zKzaDpb=*}};#kne|SvV-04kWfC<4Sdl{lbvi)z%Yx4XmUSW z>>B|2`&Np)&F1Yk@348N&G#e!{}%c(x$Ghy)#jPxTQ3^6=FF{T(R-WV9MNzIs05?j>LGILOhZED93bubFc zjNHY{Vx=8IU1l?92hj>Ew~^-_fj#8mqSg+Dh=#rN{h-qiJ+o-`Lg6Re@9;1Xp+8PT zUc9Jfqj2bVlHr#jPXiGp!?{?5@o1n<@^HvP9x~4;oc9vZ79cFlwf4v0rL4^OED%A5 zD96WN6=n;@FEG--fn=EBb7(>Y%8ZQMGCqWRUP2Q~;5}oCs%TQmO8h#kOlB~1YD{d+ z6J`PLuu^9KfS8>*K%INmq@0<1BqLdQdTa?JVP)V2tedyU+`7W8jYKIkSb5>`d73t8 zS)0z{jGMXqPgc%czs!L5-OSjlOwqz>g;fXDxHhR^lvVcXpzWTDV`;CE5wX14oEq~8 zyxKaoHBJ$ujjs}Z#qTF$p8BZ>xHu{UJhXmF3~(PcK{);bh@8rqe4KwRALQFDc_?T3 z4f#Mmluto4le3oG2f<7}$#+*<@*bSJ-GaRxc>u>3M{k14EWg>3_vPVvc^|mz?!*LE{lf9_P1pz>A>%7eBT8!H^~0E+ze_*XJ05gA%j- zH#+`!*!5oEs%f|Zgw~?e{|_>1t2&N`f>u*N&yT}24SHd*q6`i~*tt56_?F)d27`^D zbA_wgo`19DP=M2K-poe_@`9*M2O>JG+fpEiQgXcf$cJ+2!gP@+1N)}xNP=Se_6A$}HY{aU8BB}@ZW>KYa0&jpQH2$M2~I!Lv-lEB8bRJr+_EBS zOCvBRD{86Uo>yR``ayzwY00`*rA=iXQl+0ozoXv8c{S-gpMC-7m8J~nQlYle?543! qug-ZvSkw_36m9=h6e=TJg;F?g#R(e^!nai^rz-nZK(}GmTmJ%-MB-5Z literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc b/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6453e3e655fbdd5cc831197849736601110783f GIT binary patch literal 8134 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 E0-XKyXaE2J literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc b/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e852b5818430f6e3f66e5216e17b12487958d62 GIT binary patch literal 2820 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 M^w}XaNo(^b literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc b/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0e0a20918eba793ed7f3487408d724a13ad1535 GIT binary patch literal 3417 zcmbVP%a7wo8Fx8O96L@r-P6-Oy&%ZW1%gnI;KTtT*kRG|ngh+kVj)>>S9Q0${m7In zGt(|S9FT?;BZggeMuH3CLc-BkPWvaYCw`yfJZ850LM)f7zWQGE z)%VI9?Y6Dq_pg6;PXE23Y5$?c`mct?SMg>?7`Vn+q{Yl-jOKcz$2GSW8?I5VYf(LJ zxQ%jdL}uJ{o3Z6uOcOS*|BmqnH{UkgmT-6zD~sD$wXteplaCdb)rKR6M6z1TVy%1fmeNZ~8t4KpE&>jYCG9z+C^BH=;`4(i>*VHj~Q_QS-B zMKbgbhf1YM20qQ#Qgxt!U(~*F@Lk|q#LDBo@&ki}SHCvC4ZMHHoBfSxTFyAz(?&+l z#yCH(P4rPcuXE!}mk$-o_1w77zX{vi(MFBDajMJj^Ez)tu+hk5T5jgx0Ib@DF~?$B z(gTJ`mK!yJ(fl|D*A6tuXhOz2z_rM-BUm-JuEe#0J5|=$&JEQ%btXEnYvXoq@YbZ# zk8X+hjyv3$u-`Cw6Y~w+1D(lM-SK8_mvBdIE#&0(MSZFFR>iS>3CB)Jc}HDaQr1_L z)p<6~bV&ULwA{{(2|P+&&vo8i+OMT{F0_#YDZ9Cmw{mAvTgWC3|1VvC^KN05w;l4r z9Qfx^>hnT#TZwq!g~`2PD966~{-Ra}m&*}4Q`h512 z>9g5SKi``^?ahAOn?9!%upR^cc=jvIf7zRUP?0|EVfPW}p3ENYyUim+$WS6izD_d! zfF11iW^d0fK=Iz}XVa$;K(y~e7Wh4x{uS>Bc%Bi@Cs3&r2K?u=v0oU0uROOA_(>ol z*ILF^|3*=4C-Dg}YhVX$88PEQ|co{f60+Yy|?@&C8_?PEwU34GdDL+-sG*bs$Jzy_^)# zG)xrAm~yu&$~D;4GVcxjC{*X3gay~g^Yo~Lab%ovTN(&E}R!G-g2`E$dhm^mEpTRJFXO-_LgTC9N z)H(fgIsUOsnD0%W?Dre83+%!QW4fFuJS^JtsTZew7>U9}hVzw@auay+6%2h_?$Vt6 zTi&2yj|PeXx4Q`0`BlkmUKb(+_f6~PW?9)P00S)wR^$+E$3PaT$XX{Q?p9LoL}cG^L-}@{3HP z=SO8<@TNvFy69UlZ!9|$eT>-5n-^@{%$uiH-Z;}IY{IIp2eCP}C)&u)?JTD{4O%m5 z#m=Y|w?_{85C{EFeOcw(sO);)KDCyRPTrCK$Zc@$c%57yZ%njB-KVZ(qB+`V16-TY zR(x&L1;_5P%jo1A7mWorN$*>Cr3DK}^tPI8(NNX)#ypjyPPfnu-mVfndc(4xOa~&N zvVUL5%vWKWl+_K$U4kBkGG6ow{q3TL$duQ>yRd)YM?>+tlqsAtyBGNp`qubdk}JBK z6xz6W5oOg!ewN^-cqoD)deQoW6SS^{9&&dp3;f8JUYZE%cN2VzF44cBQ=iULmR+an zTs(C?0HdR5n4QcET^oh&K`PI@lXM6_e6i{-`kl{#QGfGNe zpbQQpRn)K}m&(Xb(5Vd4)UBoIi6rhX`}uXizJoU-6Hq16QGGhBS=O5#)&{Ce7j*`8 zhddsY#$l4E?)Ep_EiwfvaQWxJ3+UfLd0Kf32&F~J+B`q)q7Y~MVG@Ojm{)9*SR7?> z*+G0NA_f A5C8xG literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc b/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06d368276a9a47f3332489d8e081d952e86dfaa6 GIT binary patch literal 3118 zcmai0U2Gf25#Hk+dB-C~Gjbirwi;5RYlMkR2@YLJR#4YcoRDZN%cqErT0++odA4Zr zFMG#C>JzOLaMJ{^ffT4k_}XLwZG72(As`a+PyA>$M}ygz1g1-^JNKAqLooR-O}IXz!WabOBNHryl+ed8dIGD(n0NB>U= zNyAtNIm1nJ3YUbLdh6l9RmWqLvD_s?r)pX=HLxM1Gbt0^u5$&N(@X=kcUj+m`B-uS zjO(*6XN;Mok%0|p`uU7e$m_|RHmgn*%%oNBCR(p{icf^O^vQYK%Kr?~H~Bl%t0er>=oNeM_B4MIxZfxfW-9(9ob?vPVR_x|Pr zLW;uH{zjW2-vT4Y=8Hsm`YO(xyZhSs9WX03NN4Ul}q?yu&-w{XAo#02^ z|E=+CEa8q~E)qTJ90bTBk-T;T`r)H|h70>F z(N3mJdc^X-U7$1d1W*lAqo(C+8G-CCoduoj*KCRXsd9>gVRR?6%a z`-I)E%)@($-HimKFQ z_I?n=-a~!1>7|Gzm~%zl5_7s?Xy+Cj2y}yHXODt-0 z*@Bj~+OoR#7M$|BvQihQsa^lz!yx-5x&i%<@F!3SFpJ<4IK=K)QcBBar?k{e*iA9? zmPqw;R5zwA5pgGzH?4MLT4G?B)Y@-AB$~F=w=?FnI-}1`6*QWrc;+m(0YVTzQ>U1_ zmiP4d>+0!O)Y$29Wh^XPJRE)M!5K$-3Vs2AfmX~|9%LH1U^(mY=fRZawjlvDQo7f6 zvyOL$fmwM7TF`D3Q_$MoK)hBop<$S@!Q*pa9s>|OY(KCN|6BW^@(&)#d#iHbfgHHu zSdl|jA;g5xm)?CPP5_+JND z;>XObF=?viRp2Q`!VW2Y2Dq?;B1~93ge=U_HsI@3!48oPWSi330o&v$YC z>qvfY*6_7Dmaso5!b!>rq}ed1-hsaHlmCtD&V-l4R^3Ejw z0lSUvfVW%tWfy-a_(fNMBwU^3EO#~(ZO8&{^4lwf!iNAG6~Xo-hMs`D6E6uxaa;X( zvRD5XRRPnnEPkUUoQkXI`Ody2_cTSGybnO^3(%j1F5Ad0(sq!h`+z|8_chL4CjVwB zL+3^)mRT5ox-p(AOziRcPA4hU=G6#(Yt<6(~t zD>Q)CY(Uu+A2~A~S0+^DA$tUFlgBJ0D4&%WXezyE5}pe>F5%2@S4FFRe*^pZPi##%?MrzJaPVz@&lCBp*sV zzbbaDNyoPd^;bgutJ1(j-=3KURaUWav688_ebr*5=e{J-Ed-Erxp+W9bnHxGPeCj#T1|Q4Ma6^5<^1eODN4TK@ zVYx>{zK=sXfOI6>X0>Bnpc>ehTG&0@YU`E8j}s7#w{&V~W~Pv*c!ui1Y8Dd%0-4R^ zb&7}I5>euuk+Umxk9>>)xSpvK`GwsjAwz= hc#h-NNcS2!vPPo+Af0RE#ecita{1MB07bU^e*n1eI12y( literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/consent.py b/max_bot/app/handlers/consent.py new file mode 100644 index 0000000..6f0bf4e --- /dev/null +++ b/max_bot/app/handlers/consent.py @@ -0,0 +1,60 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, log_consent, get_template_rendered +from app.keyboards.inline import build_consent_buttons, build_contact_buttons +from app.fsm import BotState, FSM + + +async def handle_consent_request(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id, + user_data.get("first_name", ""), + user_data.get("last_name", ""), + user_data.get("username", "")) + await db.flush() + + await SettingsCache.refresh(db) + + policy_url = SettingsCache.get("consent_policy_url", "https://aegisone.ru/politica.php") + text = await get_template_rendered(db, "consent_request", policy_url=policy_url) + buttons = build_consent_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") + FSM.set_state(max_user_id, BotState.CONSENT_REQUEST) + + +async def handle_consent_given(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + + user.consent_given = True + from datetime import datetime + user.consent_timestamp = datetime.now() + user.consent_method = "button" + await log_consent(db, user.id, "given", "button", user_data.get("ip", "")) + await db.flush() + + text = await get_template_rendered(db, "contact_request") + buttons = build_contact_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons) + FSM.set_state(max_user_id, BotState.CONTACT_REQUEST) + + +async def handle_consent_refused(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + + await log_consent(db, user.id, "revoked", "button", user_data.get("ip", "")) + await db.flush() + + await SettingsCache.refresh(db) + phone_1 = SettingsCache.get("phone_1") + phone_2 = SettingsCache.get("phone_2") + email = SettingsCache.get("support_email") + + text = await get_template_rendered(db, "consent_refused", + phone_1=phone_1, phone_2=phone_2, + support_email=email) + from app.keyboards.inline import build_phone_email_buttons + subject = SettingsCache.get("email_subject") + buttons = build_phone_email_buttons(phone_1, phone_2, email, subject) + await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") + FSM.reset(max_user_id) diff --git a/max_bot/app/handlers/contact.py b/max_bot/app/handlers/contact.py new file mode 100644 index 0000000..f580149 --- /dev/null +++ b/max_bot/app/handlers/contact.py @@ -0,0 +1,43 @@ +import re +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM + + +async def handle_contact_provided(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + + phone = user_data.get("phone", "") + email = user_data.get("email", "") + + if phone: + user.phone = phone + if email: + user.email = email + await db.flush() + + text = await get_template_rendered(db, "inquiry_request") + await max_api.send_message(max_user_id, text, format="markdown") + FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST) + + +async def handle_contact_text(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + user = await get_or_create_user(db, max_user_id) + + phone_match = re.search(r'(\+?7[\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2})', text) + email_match = re.search(r'([\w.-]+@[\w.-]+\.\w+)', text) + + if phone_match: + user.phone = phone_match.group(1) + if email_match: + user.email = email_match.group(1) + + if phone_match or email_match: + await db.flush() + await handle_contact_provided(bot, user_data, db, max_api) + return + + await max_api.send_message(max_user_id, "Пожалуйста, укажите номер телефона или email, чтобы мы могли с вами связаться.") diff --git a/max_bot/app/handlers/features/__init__.py b/max_bot/app/handlers/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35cca089c6a5f0ed0fe195d72ce6182586ad368d GIT binary patch literal 1948 zcmaJ?ONUY^r}r zs6ov;x>KVTweKiSoz~WrUi}e#DLqoQ)|e1Myq=!*{0-Jq%cXTD+=RP6XP#hgmNH%* zkvhSmK`2$6#guVIVQjydh&2IashwZD2zNDU?@~{AzAhuoM-6Wi&+mBBKQZNmlHOKGAKCD+DQu2X%YUO@40DYE1>B{$Ok zd~>WuszkxF4-o$sphyQ^m}9ee zJ%}0OjvaaY8clA-0F>EJc;pFA;02=PzT&%C-0yWt4WKNIRPbQHCAOTJL}@C~$PFnk zpFelz@|Abbxo0k2dgsFG<%{Q5FFUn#-^gjE0J0mgH1*aQmn`EFv*odU>_1h_jZzJ$ z(}DIBYGmu&6G0NEyd{IxD`z4>ex<<~qV#Hxb~Of^eut2$XCIy8_K^>I@@sy;)SWJ%@6VC~Br*&nh)9T0d5ffDo{ z^r4{^sYBU&2-QOLp`lT&b=il8CTOC7vPN!>$k+y*YSg%XMw5pgl}i^UW%`Q=3B)>AR6QZ zX{P}5Zn5)7LNt3pTKo^SU+p?(EFm2NO>ysVS>b6h6@DBp_;Z*5s_UT_qAy3=T++xn zEWsBgl!fX1#ZO4R#kQEAp$!{1H_)0(J@z69VGsqvSxS8`^thYEOg8oyZ>Jf=*Rz0c zqpNQTepvc=qS_vSjRPJeJP_NZ(GRn9qgUt4u;O+Vjxyl|VfrN1D!nR~(gZBCP?VYv z+8&W-B!2g%XXUDc=_Nd=OkE)zK(0x2a_n(k13{6#q(FxRo9T|bQ~{#Q)_y(REPh3CrKLM~NSGNICb4Og0l>~;ACQ(2+Rp`*1= F{0qg+ZA$Epm6!zHTpY?9G+w>oVv{fafjiOY&B7`VWBPz8;YUsrm<9a5EQ`@^8k0Ft~ zQqxql5=cSfMq9djgi|ODL^Mec9J$yWedW{}XD;PEXEzN8qZ)xHRtc{G~YN!$=(*|Zn86B#Zq4VIJC{4vrw!eH0!0T4`3*2dt!$ugFqKzkyQ!e)8?T_S97VUA~v^z;CO#Q{0Df zE&mNHTkZTsaVOu(pXJYC`3uZ;^X+_VYRb0r@ADV=cJTo2yZP31ySNX}y>|XI-z?VJ z;I)f(++C~{>oDG(25URtDZYZJ5bat1bH0arV9R#C(*_JrX3}#2>c64)Lq2Hdzvi12 zoV{XgCT*eL5CTCq*#1+#H{DJ{FrIDakN!6W7WL+3B3_98wAiRh-+@9lz)%5#FxxYN zKtdpv>0a>NG+yYmN)6ap>WLIVPavtB8qzaC8VViPlXI6YetP5jC3p6dt5+{wpS^tX zic?F*K29S60=p4U5^s?URF`8f1R{}c>_xmhHKxAbjUq1mzzbc!8-HG^0drbjuQwCK zk}nER2HiLj^=f|=Qg#7ksS6IdIyFbT*_8qWt&}Re>6nqX;(EQnspDis6oJAhX(cj> zNt|ZjyNBZcport(^3OTQdw$7_S;$3legUMJLYnihAs!jNhnwh(s*TS$4P@ss^TPg`gkJa3p>++aDRjaBhYi8|May;?PJd& zObem^^z~{^vH$c9kc$FR)iYzA3@nrz({HImGO(F3BttS5SY{6DAbTs*GH3}0LWN$H zsSlOMYF2X_nI*E!TAqkirog%hk&g_Ov1~I6J8R1m45YqJ53JbZYS;G3qn5`;%?wW% z&ae9C%DTr6SO5ew5*~72!mJKWZ5GJ1*cGekX{fo9?rx>A=f&5cr_JI^(B#HTih;@%oerZCry!cZq>i234!sbXaz9PjDjPcsE+Jis~VVW#; zY;hFc39CxNxb%WBc@3}vRUs%1;9?reQu942j^Q38?f!5AXH6K+!kr*sg|vWe4MNFj zU>@)i=E%&Gr1HDgsXLQcDyXRXGPyo}nuH2F#XC44K?QzZ925N(lBm*s)eK6?0$)}{ fuA&`6Qei?krS(1>V;b@g@DEI|K19&cns5ILQ_hJB literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32a6f388b1b099da18c9592e116399cdf97c112e GIT binary patch literal 1608 zcmah}O>7%Q6rR}~uh;7&bs`991(GGi!B*wK2?<3Jw4?&0774e9j-acJXX4t~A2Tys zTC|qZrbs|RRS~zgG=>wLpdyeKQVtxs>|XbT;M5!9$OYbPoHnFFtTb=u{my&edo!md zCmjUe;a{fuzm^dCT{C+>6POoa$weRxG3=uNQ;hZA@QuKvWJ^V*`)Su%wI)Ga)0aNWZjdiZ_9`5~8`0fsraFMncw&SE1xr(9lZE4Lc!>jK&gO zdK)Drd2Xx$uN~VR4YOF;uLRBzKuL70(t}ubV#}|_UiL$;@C>Uk=Q2uc0J-{}aS*+X z*72~yCfMXgHK~m76KLqDiNs#Vth!3XpNYe!cKJDU9kBy9&F8@;G5e4!XlrcxV{D}y7cBR>L zob-$IZu)ilUAmQBN;m82HZb3(w}Eb_TUr3y?0xXKQwPI)>5u78V;w~MHvJ*HS_cWl z&MxXe|2hH0w_EXZkj4L=vM+Q?K&SPP%@s4X+~*x7-4#7tu~bpOMSNO^{cLYJI{9%0 zcObRRbUVA!s;9TIHJwPhnXP3X{YR1ekMHQ?cRvF*7OXZbSqIWZ33_X8h#3rHUpETF zA74i|$u;9`0(ZoS%V0K!zSuXL_21S$t;67x&xIncLe8(iRf+|d^T7odj5G|b!G+ve@Ox5qqF}K26gWwJV=QE#cc0nuboZDq z_)*96SpA_xgKN&ALPL7@U09ZIqtJTt@vzdL6U8~2_AbX_C)DAqc z%A$8d=$Qq53Ov=A%1!8AUXn`m20|l9E4rP!bT0IzrZdNuUOIW|jV1T^Y4`ObuPq%t z*`^iwz(&7YbV9-5T@`xzXiY0RQFsn!q9|KvXoPM%EoXacTTr>Xx@naqk literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f7454a1a7bcc4965b30120b05392d5eb5b82038 GIT binary patch literal 1854 zcmZuyO>7%Q6yBZP^{&@z$8~<%LJtNB2}dCncZ7hpQ9-4tl2oeZvf6sq@uuD%X2-OV z))Go9QHfUK=Ze6VxWEAcA`q359(qCIvcB$t3#Z;Vb1CnQ<0J*v+VPuj-h1=j_vX!B ztyXOXo~7SwTYp%J@+VG?KN?KlfH!>y2Bt8zr-Uk1Rg_7Ogc{XCo$8@Mjj%*Zp-D}- zruD3_Ov|B7ZB_9rO#fVE1}ojssl&{5rDZ(D%2-t-$)Q8JaOr$& z(w2RQn3(5sVYhYqGBssY16Jt5za@@{i{XQJ7Z_HXX$V+e*oLd(Heu z(0DMu4Vt_8uj8HO%U8~?EH%%sp1Zcxymo0F#*J$mt>vQD^-|XpaAdooABmM>=4j%I z?ZkfuNIcQ)&qDtHlQ`A8!DhE0K~&Ul#{7ocjr%-xQ{i#(qzpDi?HCMg^rQhGb>cqs zYGe>b^r&BHJq>#h(mf(2FaqM~^fN=voI6?GYKj7Ogo*Q08g&GbsxM(Fjhy*wq-;jhAaz%`w(iY zPPOZ?;CMk(!md*@^lrIc63_|?2Pd0XV7bt7Ms=j|-@$4?K9U8MEHucPS^&tjXf0@C zaMMV>R(KPf{rwU|c(&_BtOqyk*$!O8eF*4m(v2ZJ?k6!9%gJ^zJ5h0u<48n$83skQ z)HdDLg0pej_^&}xyQ`PU)`_+3@zxUowrxPI< zSN>Sq|I?A#$v?h?1zvokOEiQmXh7UUrb z^N2(ku@-f|!qlVw2M%e|V1RnzKj0JfuK^d zuGgBqF)QLxS-i!#NMu?Ti*3pBab}R56_jyC!RgwtN;4YYsgf*}hw-qIvMfZ;(!qTq^qG4u{l?M~a&pJD#+omJTZZHfBkR!4Xey@8604w4rRBK|u@ z=DXFLc@u%R<=UY^9es&t_%+}c(crdt`j~mk!u$WVUbIF0hNqNjf1DgI1C*+|yn4IRHO0*7w!@`N!&BSKU*`>IVbus_)d#^G~~Q2%N3=Rw2Y?JenllFNJ+T{#__FBXq zmXe1ighhwM&y%}xGEPa$2qzSC)0MqVi6AvhbhWQ5ZHL(8sY%(7A z_mYBU&=>vN&@>eU>!0NW$XFjA>cC_HQN3x09JoVgi5uEAZYF^SRW7jZf!W2Lo?%T%8_9G2SX@CF# literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1a1009d5ca6f66ac72151ce941317c591727493 GIT binary patch literal 2998 zcma)8-ESOM6~A}BcXqwDx8Tr-j}rpQAW*L>)REC>ym#WPJ^PV6 zvrUXey+nYG>&WB;bf!;g;18NBfdh+u+8ti?UfX{<$BOZRj&)+3{3 zdS=V=EHyVGyXAOJtKbzdH$7KaUQyV>d5w9ca7q+@&xI?BuNhuhl*AOc)1q8wwdq6n zVKtsxaTtY-1f!{Xm?X{iYW#fAxE$8Bys{c5en(vz?@#fSK#(>p$>HvKg=YK|8)0%GfKYS)7F31}DFhhmyAN{ta(@21IZqi^Tg# zJY@qd(Nn(32S&=*Orfu9Lq4!DwugMkUqETT$NE~T4V)nx6ow3G85bovKsM8eXAv>H z*bC#N*=aA;TyHk$cIR6i5k~RkPdd={?C@IH=p~`lFw4!WmywfKXvTifNSd#Np3{*? z(_h)}+_)pDCYXA5+z6sT#CWF>@>Ug-E+?@zZ{@Y*UllH z<+zE$1>tHl?zF?@R&dQ<=_JdDZ22;jB9v?S66)Q9k>zpO8XXzVcQ^8BKk5X+zcR@v zrV|xcKo~D^gFD>$$k2@Y+DEP_r@=)P3bbjy2$G;-kmBSi;h$Ek!v6ymOS#~Wvpxzv zzzj!-&{Jc``&MG4+U8Nw?bME!Qhi{ihA@WOcq=%Awnf@>GV3h05_`>A z*X0iQh18(gP=l5$%%s?4O)b&ZO0ZxZW2aea3lkAI6RUPImD-#9cT6~`vsT7`;imb( z6WOQL12$gYWJ9fANDBjH9b;FG;RWMj>W;0gn{RVzCDW;q7E?RbQhn1rid{u)4rYc- zxa$`5o)AT)2&BH`B4Sq@-uX)FaZlN!;Z}V@zE!{cVP5jv`>Nd;s6D+IJG- z!^_7iF|O+jtv$w&`RpfZQMqY9@YA0A^o(kcUG!{q2AU!_lJHuRvtAuLpS_*k$!=%6 zqZ`>)b@XbsH@cqPek$7o_kBh8h>6)>AkVfj+08z{dk4RRD#&iOlO2q%k8X@Mvkx)f znpd5d8-CL2_Pzxd|9SV($>>vn?o39G^Jhy4gF~LG~f+!&vpy z(?8XoaeMQ0Jl*UM2yUA~9KC_jZuRb;;dATmeiec3XL~e99HSd|_vdRykIpyAJ$)mL z@9`&dergG9!k4j{r?orx_)|Im`uW~j;=T*d?<1alq;QK;$+k{b5y$`S^^mZpM;)kg zeI%88{OR5oAaUJK5Bu3#CD#F{xfv(2*_Af*y#gU@Oh~GlI4{KjS-%y=aj+WZCSV~< zBxU7Q&M!Q-^wQ#jzxYD^f?t3Bhl>kd0n+j6yx`ZD7JlSkSa>-v`OjUf|9D}su4eXF z0l*c7ZO=Kvlf2?zYPOs4W&Z)}VY2EA?4Ihf-GhoNx2VsD^8SZLj zfL?{W+|XQJ=5yd0T;p^1UBl2SfW#6s<{&LY8!vc<=3wLy7n;@kB~zY+y@p9vClBEw zZG^(c+ZYJmH&g8#`^oV>K0ZJkYYm?t>7*t`8oJxO;^0GN3T;z^CVi-wJR^)g5UuaD zPl98CvnXtE9B__AT2vWV06D~%0q!P4yk@ULjLR~8tAWR!oBQP??qxzC+y(2 zi$0|G-$qy5Qawi`yFJ<*{erZs7@$+BE3-SJH_$BEdjJkSv%>3m*qqO+k-dM+^sm5hedb(AjQpjtu-3x^Rj2b(903sNz0HqCcus z-lKSdN!Lejjd76D*a09-z>z#`Q*NW5BLgbmKJbfz-_8!aqUx>j1p#*kGbUg}mjx?f z7I6}BU?ctA44QHS02Zod;-L?u7=Ik%So=cnr%r3 zS$>`f9bs7o$@OlhD+!9Q?5o6mlE~MHQ1i;K5IIGpMufJ`(-Dbhpi85V^`-nW#0z+1 zGQiZe1D%F$r0%m+4^F6Npx-{n=g@a*)y3Kj?oU^uAj0*swV^IsUWKk?z$|s~^c!^Z zlyoihW=PQJv|qu6NL51`V5bmV&`?re7lA7m3i#CpSd1k*MBQt{+N*u4Gan djB^@S!Cn-8hfq8wFr!3f02ByZcii%+{{qO`@HPMd literal 0 HcmV?d00001 diff --git a/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6224889ef6e37f4316d056291c166850330be0d2 GIT binary patch literal 1566 zcmbtU%WoS+7@ygf*K0d&-Nx>vYx?TZ{a2k=AGD>l6L`G~fGq#vj$!2874s+rft5G6cZXHu@bLT^g z)p@N)y6*3ggLG-x>ItR7WG{Ow80?F#QFiu(@>A&#qzIJo^GwL{Mbs$~k0Mk_k#Hde z2WR)qRP_`nOLMFDE~KlYzs~~|3@og$_!{tcVf+ju+oFUNl+%~V*ed7=fam7KIH40V zwv};W7S@C+YiQp!A4c&Awhlx|fkqvw;{E*=rEv4x7)T>P0>KO|=Sx*PrX`d6wcz z{bZYayuq8ib!?8k%6|#+EEV1fDeA*(I3@{fx@sAuf*C;ALEQ~d+vnZoJ}U7qT1W*_NO zeU7=-^G&Gvzq3d<7X%K~_38xnhhm`o{-K`)vA_cfEmmhUivoWa{$uQ#rJbojD(3d{EKEd}F;65)$dL>)!Rm9cP_%2Y#vF0gP}zceau5!LABh7I zl@4T4dB$vr8wlpcfxOGp_Y=5M4m2AFs@o||AO`Ena3mc-#p=iuMha=8+}XOdxxMq= zEg$yT?`^}n2N0h5ux17~*N*{YuqW6n!DzG|0@ghls4z{k^-(HS5c#|>@gb6UX-iuQ zNKhIfmw2H|3%5hI(2g{+W$ds5?Z;`N_M^jYgPHwQNqE8tGkBlX;^3Yij6&AH#p1Y1 zd=9SEF@YRrLNZpb81ds^^c9h~FMqxUNH_L_ghxVV8@uo><$!7fE6-_LA03vs6&mvJScM7!tStPS;}{ zqzkC19!r0Kucb7=#@c8Yq{#t*tB92NP*-fR8rpWVxZ0j-&q|Aj165_Mn!7T+1YxCn TBTji9iJN#CGHjs list: + start_h = SettingsCache.get_int("work_hours_start", 9) + end_h = SettingsCache.get_int("work_hours_end", 18) + slots = [] + for h in range(start_h, end_h): + slots.append(f"{h:02d}:00-{h + 1:02d}:00") + return slots + + +async def _confirm_appointment(bot, user_data, db, max_api, tmp): + max_user_id = user_data["user_id"] + date_str = tmp.get(_DATE_KEY, "—") + slot_str = tmp.get(_SLOT_KEY, "—") + user = await get_or_create_user(db, max_user_id) + text = await get_template_rendered(db, "appointment_confirm", + date=f"{date_str} {slot_str}", + engineer="будет назначен") + await max_api.send_message(max_user_id, text, format="markdown") + + conv = await _create_appointment_conv(db, user.id, f"{date_str} {slot_str}") + from app.integrations import _1c_unf as ic_integration + await ic_integration.send_to_1c(db, conv) + + FSM.clear_temp(max_user_id) + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + + +async def _create_appointment_conv(db, user_id, details): + from app.conversation import create_conversation, add_message + from sqlalchemy import select + from app.models.models import BotCategory + result = await db.execute( + select(BotCategory).where(BotCategory.name == "Обслуживание").limit(1) + ) + cat = result.scalar_one_or_none() + conv = await create_conversation(db, user_id) + conv.inquiry_text = f"Запись на визит: {details}" + conv.inquiry_type = cat.id if cat else None + conv.priority = "normal" + await db.flush() + await add_message(db, conv.id, "in", details) + return conv diff --git a/max_bot/app/handlers/features/broadcasts.py b/max_bot/app/handlers/features/broadcasts.py new file mode 100644 index 0000000..4d19b53 --- /dev/null +++ b/max_bot/app/handlers/features/broadcasts.py @@ -0,0 +1,44 @@ +from datetime import datetime +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM + + +async def handle_broadcast_send(bot, user_data: dict, db, max_api): + from app.models.models import BotUser + from sqlalchemy import select + + text = user_data.get("text", "").strip() + if not text: + await max_api.send_message( + user_data["user_id"], + "Укажите текст рассылки в параметре broadcast_text." + ) + return + + result = await db.execute(select(BotUser).where(BotUser.consent_given == True)) + users = result.scalars().all() + sent = 0 + failed = 0 + sent_ids = [] + for u in users: + try: + await max_api.send_message(user_id=u.max_user_id, text=text, format="markdown") + sent += 1 + sent_ids.append(u.max_user_id) + except Exception: + failed += 1 + + from app.models.models import BotBroadcast + bc = BotBroadcast( + text=text, + sent_at=datetime.now(), + recipient_count=len(users), + recipient_ids=sent_ids, + status="sent", + ) + db.add(bc) + await db.flush() + + result = f"📨 Рассылка завершена.\nОтправлено: {sent}\nОшибок: {failed}" + await max_api.send_message(user_data["user_id"], result) diff --git a/max_bot/app/handlers/features/commercial.py b/max_bot/app/handlers/features/commercial.py new file mode 100644 index 0000000..a5fb027 --- /dev/null +++ b/max_bot/app/handlers/features/commercial.py @@ -0,0 +1,101 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + +_STEPS = ["service_type", "scope", "address", "comment"] +_STEP_LABELS = { + "service_type": "Тип услуги (Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, Обслуживание, Другое)", + "scope": "Объём работ (краткое описание)", + "address": "Адрес объекта", + "comment": "Дополнительные пожелания (или отправьте «-» для пропуска)", +} + +_COLLECT_KEY = "commercial_data" +_STEP_KEY = "commercial_step" + + +async def handle_commercial(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + state = FSM.get_state(max_user_id) + + if state != BotState.FEATURE_COMMERCIAL: + FSM.set_state(max_user_id, BotState.FEATURE_COMMERCIAL) + FSM.set_temp(max_user_id, {_STEP_KEY: 0, _COLLECT_KEY: {}}) + await max_api.send_message( + max_user_id, + f"📄 **Коммерческое предложение**\n\nШаг 1/{len(_STEPS)}:\n{_STEP_LABELS['service_type']}" + ) + return + + tmp = FSM.get_temp(max_user_id) + step = tmp.get(_STEP_KEY, 0) + data = tmp.get(_COLLECT_KEY, {}) + + if step >= len(_STEPS): + await _finish_commercial(bot, user_data, db, max_api, data) + return + + step_key = _STEPS[step] + if not text and step_key != "comment": + await max_api.send_message(max_user_id, f"Пожалуйста, заполните поле:\n{_STEP_LABELS[step_key]}") + return + + data[step_key] = text if text else "" + step += 1 + + if step < len(_STEPS): + FSM.set_temp(max_user_id, {_STEP_KEY: step, _COLLECT_KEY: data}) + next_key = _STEPS[step] + await max_api.send_message( + max_user_id, + f"📄 Шаг {step + 1}/{len(_STEPS)}:\n{_STEP_LABELS[next_key]}" + ) + else: + await _finish_commercial(bot, user_data, db, max_api, data) + + +async def _finish_commercial(bot, user_data, db, max_api, data): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + summary = ( + f"📄 **Коммерческое предложение**\n\n" + f"Услуга: {data.get('service_type', '—')}\n" + f"Объём: {data.get('scope', '—')}\n" + f"Адрес: {data.get('address', '—')}\n" + f"Комментарий: {data.get('comment', '—')}\n\n" + f"Спасибо, {user.first_name}! Мы подготовим КП и свяжемся с вами." + ) + await max_api.send_message(max_user_id, summary, format="markdown") + + conv_text = ( + f"Коммерческое предложение: услуга={data.get('service_type', '')}, " + f"объём={data.get('scope', '')}, адрес={data.get('address', '')}, " + f"комментарий={data.get('comment', '')}" + ) + conv = await _create_commercial_conv(db, user.id, conv_text) + from app.integrations import _1c_unf as ic_integration + await ic_integration.send_to_1c(db, conv) + + FSM.clear_temp(max_user_id) + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + + +async def _create_commercial_conv(db, user_id, details): + from app.conversation import create_conversation, add_message + from sqlalchemy import select + from app.models.models import BotCategory + result = await db.execute( + select(BotCategory).where(BotCategory.name == "Консультация").limit(1) + ) + cat = result.scalar_one_or_none() + conv = await create_conversation(db, user_id) + conv.inquiry_text = details + conv.inquiry_type = cat.id if cat else None + await db.flush() + await add_message(db, conv.id, "in", details) + return conv diff --git a/max_bot/app/handlers/features/my_objects.py b/max_bot/app/handlers/features/my_objects.py new file mode 100644 index 0000000..0ef1dae --- /dev/null +++ b/max_bot/app/handlers/features/my_objects.py @@ -0,0 +1,39 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_my_objects(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + + if FSM.get_state(max_user_id) != BotState.FEATURE_MY_OBJECTS: + FSM.set_state(max_user_id, BotState.FEATURE_MY_OBJECTS) + user = await get_or_create_user(db, max_user_id) + await max_api.send_message(max_user_id, + "🏢 **Мои объекты**\n\n" + "Укажите название вашей компании для поиска объектов." + ) + return + + if not text: + await max_api.send_message(max_user_id, "Укажите название компании.") + return + + from app.integrations.portal_db import get_objects_for_customer + objects = await get_objects_for_customer(db, text) + + if not objects: + await max_api.send_message(max_user_id, "Объекты не найдены. Проверьте название компании.") + else: + lines = [f"🏢 **{obj.name}** — {obj.status}" for obj in objects[:10]] + msg = "Ваши объекты:\n\n" + "\n".join(lines) + if len(objects) > 10: + msg += f"\n\n...и ещё {len(objects) - 10} объектов" + await max_api.send_message(max_user_id, msg, format="markdown") + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/notifications.py b/max_bot/app/handlers/features/notifications.py new file mode 100644 index 0000000..436b047 --- /dev/null +++ b/max_bot/app/handlers/features/notifications.py @@ -0,0 +1,68 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_notifications(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + payload = user_data.get("payload", "") + + if FSM.get_state(max_user_id) != BotState.FEATURE_NOTIFICATIONS: + FSM.set_state(max_user_id, BotState.FEATURE_NOTIFICATIONS) + user = await get_or_create_user(db, max_user_id) + from sqlalchemy import select + from app.models.models import BotNotificationSubscription + result = await db.execute( + select(BotNotificationSubscription).where( + BotNotificationSubscription.user_id == user.id + ).limit(1) + ) + sub = result.scalar_one_or_none() + status_change = "✅" if sub and sub.notify_on_status_change else "⬜" + reply = "✅" if sub and sub.notify_on_reply else "⬜" + broadcast = "✅" if sub and sub.notify_on_broadcast else "⬜" + text = ( + "🔔 **Уведомления**\n\n" + "Выберите, о чём уведомлять:\n\n" + f"{status_change} /status — Смена статуса обращения\n" + f"{reply} /reply — Ответ оператора\n" + f"{broadcast} /broadcast — Новости и рассылки\n\n" + "Нажмите на пункт, чтобы переключить." + ) + await max_api.send_message(max_user_id, text, format="markdown") + return + + user = await get_or_create_user(db, max_user_id) + from sqlalchemy import select + from app.models.models import BotNotificationSubscription + result = await db.execute( + select(BotNotificationSubscription).where( + BotNotificationSubscription.user_id == user.id + ).limit(1) + ) + sub = result.scalar_one_or_none() + if not sub: + sub = BotNotificationSubscription(user_id=user.id) + db.add(sub) + await db.flush() + + toggle_map = { + "/status": "notify_on_status_change", + "/reply": "notify_on_reply", + "/broadcast": "notify_on_broadcast", + } + key = payload if payload.startswith("/") else (payload if payload in toggle_map else user_data.get("text", "").strip()) + if key in toggle_map: + col = toggle_map[key] + current = getattr(sub, col) + setattr(sub, col, not current) + await db.flush() + await max_api.send_message(max_user_id, f"Настройка «{key}» изменена.") + else: + await max_api.send_message(max_user_id, "Нажмите /status, /reply или /broadcast для переключения.") + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/photo_report.py b/max_bot/app/handlers/features/photo_report.py new file mode 100644 index 0000000..6669dae --- /dev/null +++ b/max_bot/app/handlers/features/photo_report.py @@ -0,0 +1,69 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_open_conversation, create_conversation, add_message, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_photo_report(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + + if FSM.get_state(max_user_id) != BotState.FEATURE_PHOTO_REPORT: + FSM.set_state(max_user_id, BotState.FEATURE_PHOTO_REPORT) + await max_api.send_message(max_user_id, + "📸 **Фото-отчёт**\n\n" + "Отправьте фото проблемы и опишите, что произошло.\n" + "Вы можете прикрепить документ (PDF, DOCX, XLSX, TXT)." + ) + return + + user = await get_or_create_user(db, max_user_id) + conv = await get_open_conversation(db, user.id) + if not conv: + conv = await create_conversation(db, user.id) + + has_attachment = user_data.get("has_attachment", False) + attachment_type = user_data.get("attachment_type", "") + attachment_path = user_data.get("attachment_path", "") + + conv.inquiry_text = text or "Фото-отчёт" + conv.has_attachment = has_attachment + conv.attachment_type = attachment_type + conv.attachment_path = attachment_path + conv.priority = "high" + await db.flush() + + await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path) + + ooh = not SettingsCache.is_work_hours() + if ooh: + start = SettingsCache.get_int("work_hours_start", 9) + end = SettingsCache.get_int("work_hours_end", 18) + ooh_text = await get_template_rendered(db, "out_of_hours", + work_hours_start=start, work_hours_end=end) + await max_api.send_message(max_user_id, ooh_text) + + text = await get_template_rendered(db, "confirmation_ooh" if ooh else "confirmation", + first_name=user.first_name, conv_id=conv.id) + await max_api.send_message(max_user_id, text, format="markdown") + + from app.integrations import _1c_unf as ic_integration + await ic_integration.send_to_1c(db, conv) + + from app.models.models import Incident + incident = Incident( + object_id=0, + reported_by=None, + title=f"Фото-отчёт от {user.first_name or 'клиента'}", + description=text or "Фото-отчёт через бота", + severity="P3", + status="open", + ) + db.add(incident) + await db.flush() + conv.attachment_path = f"incident_{incident.id}" + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/quality_rate.py b/max_bot/app/handlers/features/quality_rate.py new file mode 100644 index 0000000..35782fc --- /dev/null +++ b/max_bot/app/handlers/features/quality_rate.py @@ -0,0 +1,35 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered +from app.keyboards.inline import build_quality_buttons, build_main_menu_buttons +from app.fsm import BotState, FSM + + +async def handle_quality_rate(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + payload = user_data.get("payload", "") + + if FSM.get_state(max_user_id) != BotState.FEATURE_QUALITY_RATE: + FSM.set_state(max_user_id, BotState.FEATURE_QUALITY_RATE) + conv = await get_open_conversation(db, (await get_or_create_user(db, max_user_id)).id) + conv_id = conv.id if conv else "—" + text = await get_template_rendered(db, "quality_request", conv_id=conv_id) + buttons = build_quality_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons) + return + + if payload.startswith("rate_"): + try: + rating = int(payload.replace("rate_", "")) + except ValueError: + return + await max_api.send_message(max_user_id, f"Спасибо за оценку: {rating}/5! Ваш отзыв важен для нас.") + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + return + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/reminders.py b/max_bot/app/handlers/features/reminders.py new file mode 100644 index 0000000..afee5e7 --- /dev/null +++ b/max_bot/app/handlers/features/reminders.py @@ -0,0 +1,48 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_reminders(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + + if FSM.get_state(max_user_id) != BotState.FEATURE_REMINDERS: + FSM.set_state(max_user_id, BotState.FEATURE_REMINDERS) + user = await get_or_create_user(db, max_user_id) + phone = user.phone or "" + + from sqlalchemy import select, func + from app.models.models import BotConversation, BotTicket, Customer + + open_convs = await db.execute( + select(func.count(BotConversation.id)) + .where(BotConversation.status.in_(["open", "handoff"])) + ) + total_open = open_convs.scalar() or 0 + + open_tickets = await db.execute( + select(func.count(BotTicket.id)) + .where(BotTicket.status.in_(["open", "in_progress"])) + ) + total_tickets = open_tickets.scalar() or 0 + + my_convs = await db.execute( + select(func.count(BotConversation.id)) + .where(BotConversation.user_id == user.id, BotConversation.status.in_(["open", "handoff"])) + ) + my_open = my_convs.scalar() or 0 + + text = ( + "🔔 **Напоминания**\n\n" + f"У вас открыто обращений: {my_open}\n" + f"Всего в системе: {total_open} обращений, {total_tickets} заявок\n\n" + "Напоминания о плановом ТО настраиваются в портале." + ) + await max_api.send_message(max_user_id, text, format="markdown") + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + return diff --git a/max_bot/app/handlers/features/risk_score.py b/max_bot/app/handlers/features/risk_score.py new file mode 100644 index 0000000..f294715 --- /dev/null +++ b/max_bot/app/handlers/features/risk_score.py @@ -0,0 +1,100 @@ +from sqlalchemy import select +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + +_ANSWERS_KEY = "risk_answers" +_STEP_KEY = "risk_step" + + +async def _load_questions(db): + from app.models.models import BotRiskQuestion + result = await db.execute( + select(BotRiskQuestion) + .where(BotRiskQuestion.is_active == True) + .order_by(BotRiskQuestion.sort_order) + ) + return result.scalars().all() + + +async def handle_risk_score(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + state = FSM.get_state(max_user_id) + + questions = await _load_questions(db) + if not questions: + await max_api.send_message(max_user_id, "Анкета риск-инжиниринга временно недоступна.") + FSM.transition(max_user_id, "cancel") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + return + + if state != BotState.FEATURE_RISK_SCORE: + FSM.set_state(max_user_id, BotState.FEATURE_RISK_SCORE) + FSM.set_temp(max_user_id, {_STEP_KEY: 0, _ANSWERS_KEY: {}}) + q = questions[0] + await max_api.send_message( + max_user_id, + f"📊 **Risk Score — анкета**\n\nВопрос 1/{len(questions)}:\n{q.question}\n\nОтветьте «да» или «нет»." + ) + return + + tmp = FSM.get_temp(max_user_id) + step = tmp.get(_STEP_KEY, 0) + answers = tmp.get(_ANSWERS_KEY, {}) + + if step >= len(questions): + await _finish_risk_score(max_user_id, answers, questions, db, max_api) + return + + if text.lower() in ("да", "yes", "+", "1"): + answers[questions[step].question_key] = True + elif text.lower() in ("нет", "no", "-", "0"): + answers[questions[step].question_key] = False + else: + await max_api.send_message(max_user_id, "Пожалуйста, ответьте «да» или «нет».") + return + + step += 1 + if step < len(questions): + q = questions[step] + FSM.set_temp(max_user_id, {_STEP_KEY: step, _ANSWERS_KEY: answers}) + await max_api.send_message( + max_user_id, + f"📊 Вопрос {step + 1}/{len(questions)}:\n{q.question}\n\n«да» или «нет»?" + ) + else: + await _finish_risk_score(max_user_id, answers, questions, db, max_api) + + +async def _finish_risk_score(max_user_id, answers, questions, db, max_api): + score = 0 + for q in questions: + if answers.get(q.question_key): + score += q.weight + score = min(score, 100) + + if score < 20: + label = "Низкий" + rec = "Поддерживайте текущий уровень обслуживания" + elif score < 50: + label = "Средний" + rec = "Рекомендуем провести аудит систем" + elif score < 75: + label = "Высокий" + rec = "Необходимо срочное обслуживание" + else: + label = "Критический" + rec = "Требуется немедленное вмешательство" + + tmpl = await get_template_rendered(db, "risk_result", score=score, label=label, recommendation=rec) + await max_api.send_message(max_user_id, tmpl, format="markdown") + + FSM.clear_temp(max_user_id) + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/sla_status.py b/max_bot/app/handlers/features/sla_status.py new file mode 100644 index 0000000..8d3376d --- /dev/null +++ b/max_bot/app/handlers/features/sla_status.py @@ -0,0 +1,54 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_sla_status(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + + if FSM.get_state(max_user_id) != BotState.FEATURE_SLA_STATUS: + FSM.set_state(max_user_id, BotState.FEATURE_SLA_STATUS) + await max_api.send_message(max_user_id, + "🔍 **Проверка SLA статуса**\n\n" + "Укажите номер или название объекта." + ) + return + + if not text: + await max_api.send_message(max_user_id, "Укажите номер или название объекта.") + return + + from app.integrations.portal_db import get_object_by_name_or_id, get_sla_for_object, get_last_incident_for_object, get_active_tasks_for_object + obj = await get_object_by_name_or_id(db, text) + if not obj: + await max_api.send_message(max_user_id, "Объект не найден.") + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + return + + sla = await get_sla_for_object(db, obj.id) + if not sla: + text = await get_template_rendered(db, "sla_not_found") + await max_api.send_message(max_user_id, text) + else: + level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"} + last_incident = await get_last_incident_for_object(db, obj.id) + open_tasks = await get_active_tasks_for_object(db, obj.id) + last_visit = last_incident.created_at.strftime("%d.%m.%Y") if last_incident else "—" + tasks_count = len(open_tasks) + text = await get_template_rendered(db, "sla_found", + object_name=obj.name, + service_level=level_map.get(sla.service_level, sla.service_level), + status=sla.status, + price=sla.sla_price_monthly or 0) + text += f"\n📅 Последний визит: {last_visit}\n📋 Открытых задач: {tasks_count}" + await max_api.send_message(max_user_id, text, format="markdown") + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/sla_ticket.py b/max_bot/app/handlers/features/sla_ticket.py new file mode 100644 index 0000000..d5189f0 --- /dev/null +++ b/max_bot/app/handlers/features/sla_ticket.py @@ -0,0 +1,155 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.fsm import BotState, FSM +from app.keyboards.inline import build_main_menu_buttons + + +async def handle_sla_ticket(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + + if FSM.get_state(max_user_id) != BotState.SLA_TICKET_CREATE: + FSM.set_state(max_user_id, BotState.SLA_TICKET_CREATE) + await max_api.send_message(max_user_id, "🔧 **Заявка по SLA**\n\nПроверяю ваш SLA контракт...") + + user = await get_or_create_user(db, max_user_id) + phone = user.phone or "" + if not phone: + await max_api.send_message(max_user_id, "Не могу найти ваш номер телефона. Укажите его, пожалуйста.") + return + + from sqlalchemy import select + from app.models.models import Customer, Object, SLAContract + result = await db.execute( + select(Customer).where(Customer.contact_phone.ilike(f"%{phone[-10:]}%")).limit(1) + ) + customer = result.scalar_one_or_none() + if not customer: + await _send_no_sla(bot, user_data, db, max_api) + return + + result = await db.execute( + select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1) + ) + obj = result.scalar_one_or_none() + if not obj: + await _send_no_sla(bot, user_data, db, max_api) + return + + result = await db.execute( + select(SLAContract).where( + SLAContract.object_id == obj.id, + SLAContract.status == "active" + ).order_by(SLAContract.created_at.desc()).limit(1) + ) + sla = result.scalar_one_or_none() + if not sla: + await _send_no_sla(bot, user_data, db, max_api) + return + + level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"} + text = await get_template_rendered(db, "sla_ticket_found", + object_name=obj.name, + service_level=level_map.get(sla.service_level, sla.service_level)) + await max_api.send_message(max_user_id, text, format="markdown") + return + + if not text: + await max_api.send_message(max_user_id, "Опишите неисправность. Вы можете прикрепить фото или документ.") + return + + user = await get_or_create_user(db, max_user_id) + + from sqlalchemy import select, func + from app.models.models import Customer, Object, SLAContract, BotTicket + result = await db.execute( + select(Customer).where(Customer.contact_phone.ilike(f"%{user.phone[-10:]}%")).limit(1) + ) + customer = result.scalar_one_or_none() + obj = None + sla = None + if customer: + result = await db.execute( + select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1) + ) + obj = result.scalar_one_or_none() + if obj: + result = await db.execute( + select(SLAContract).where( + SLAContract.object_id == obj.id, SLAContract.status == "active" + ).order_by(SLAContract.created_at.desc()).limit(1) + ) + sla = result.scalar_one_or_none() + + priority_map = {"start": "low", "business": "normal", "enterprise": "high"} + response_map = {"start": "4 часа", "business": "2 часа", "enterprise": "30 минут"} + sla_level = sla.service_level if sla else "start" + priority = priority_map.get(sla_level, "normal") + + result = await db.execute(select(func.count(BotTicket.id))) + ticket_num = f"T-{result.scalar() + 1:04d}" + + from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus + ticket = BotTicket( + ticket_number=ticket_num, + user_id=user.id, + object_id=obj.id if obj else None, + customer_id=customer.id if customer else None, + title=text[:100], + description=text, + priority=priority, + status="open", + created_by="bot", + sla_contract_id=sla.id if sla else None, + has_attachment=user_data.get("has_attachment", False), + attachment_type=user_data.get("attachment_type", ""), + attachment_path=user_data.get("attachment_path", ""), + ) + db.add(ticket) + await db.flush() + + db.add(BotTicketMessage(ticket_id=ticket.id, direction="in", text=text, sender="client")) + db.add(BotTicketStatus(ticket_id=ticket.id, old_status="", new_status="open", changed_by="bot")) + await db.flush() + + has_attachment = user_data.get("has_attachment", False) + attachment_type = user_data.get("attachment_type", "") + attachment_path = user_data.get("attachment_path", "") + if has_attachment: + ticket.has_attachment = True + ticket.attachment_type = attachment_type + ticket.attachment_path = attachment_path + await db.flush() + + text = await get_template_rendered(db, "sla_ticket_created", + ticket_number=ticket_num, + priority=priority, + response_time=response_map.get(sla_level, "2 часа")) + await max_api.send_message(max_user_id, text, format="markdown") + + from app.max_notifications import notify_ticket_created as nt + await nt(db, ticket, max_api) + + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) + + +async def _send_no_sla(bot, user_data, db, max_api): + max_user_id = user_data["user_id"] + await SettingsCache.refresh(db) + phone_1 = SettingsCache.get("phone_1") + phone_2 = SettingsCache.get("phone_2") + email = SettingsCache.get("support_email") + text = await get_template_rendered(db, "sla_ticket_not_found", + phone_1=phone_1, phone_2=phone_2, + support_email=email) + from app.keyboards.inline import build_phone_email_buttons + subject = SettingsCache.get("email_subject") + buttons = build_phone_email_buttons(phone_1, phone_2, email, subject) + await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") + FSM.transition(max_user_id, "done") + buttons = build_main_menu_buttons() + menu_text = await get_template_rendered(db, "main_menu") + await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/greeting.py b/max_bot/app/handlers/greeting.py new file mode 100644 index 0000000..5c43003 --- /dev/null +++ b/max_bot/app/handlers/greeting.py @@ -0,0 +1,33 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered +from app.keyboards.inline import build_main_menu_buttons +from app.fsm import BotState, FSM + + +async def handle_start(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + first_name = user_data.get("first_name", "") + last_name = user_data.get("last_name", "") + username = user_data.get("username", "") + + user = await get_or_create_user(db, max_user_id, first_name, last_name, username) + await db.flush() + + await SettingsCache.refresh(db) + + open_conv = await get_open_conversation(db, user.id) + name = SettingsCache.get_assistant_name() + + if open_conv: + text = await get_template_rendered(db, "greeting_returning", + first_name=user.first_name or first_name, + conv_id=open_conv.id) + buttons = build_main_menu_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") + FSM.set_state(max_user_id, BotState.IDLE) + return + + text = await get_template_rendered(db, "greeting", name=name) + buttons = build_main_menu_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") + FSM.set_state(max_user_id, BotState.IDLE) diff --git a/max_bot/app/handlers/handoff.py b/max_bot/app/handlers/handoff.py new file mode 100644 index 0000000..2c3dc45 --- /dev/null +++ b/max_bot/app/handlers/handoff.py @@ -0,0 +1,32 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered +from app.fsm import BotState, FSM + + +async def handle_handoff_request(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + + if not SettingsCache.is_work_hours(): + from app.handlers.out_of_hours import handle_out_of_hours + await handle_out_of_hours(bot, user_data, db, max_api) + return + + text = await get_template_rendered(db, "handoff") + await max_api.send_message(max_user_id, text) + + conv = await get_open_conversation(db, user.id) + if conv: + conv.status = "handoff" + conv.priority = "high" + await db.flush() + + from app.integrations import _1c_unf as ic_integration + await ic_integration.send_to_1c(db, conv) + + FSM.set_state(max_user_id, BotState.HANDOFF_REQUEST) + + +async def handle_inquiry_fallback(bot, user_data, db, max_api): + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) diff --git a/max_bot/app/handlers/inquiry.py b/max_bot/app/handlers/inquiry.py new file mode 100644 index 0000000..2e6bfd7 --- /dev/null +++ b/max_bot/app/handlers/inquiry.py @@ -0,0 +1,122 @@ +from app.settings_cache import SettingsCache +from app.conversation import ( + get_or_create_user, get_open_conversation, create_conversation, + add_message, auto_categorize_text, get_template_rendered +) +from app.integrations.yandex_gpt import categorize_with_gpt +from app.keyboards.inline import build_category_buttons +from app.fsm import BotState, FSM + + +async def handle_inquiry(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + text = user_data.get("text", "").strip() + user = await get_or_create_user(db, max_user_id) + + if not text: + await max_api.send_message(max_user_id, "Пожалуйста, опишите ваш вопрос.") + return + + conv = await get_open_conversation(db, user.id) + if not conv: + conv = await create_conversation(db, user.id) + + conv.inquiry_text = text + + has_attachment = user_data.get("has_attachment", False) + attachment_type = user_data.get("attachment_type", "") + attachment_path_str = user_data.get("attachment_path", "") + if has_attachment: + conv.has_attachment = True + conv.attachment_type = attachment_type + conv.attachment_path = attachment_path_str + + await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path_str) + + if has_attachment and attachment_path_str and max_api: + try: + file_url = await max_api.get_file_url(attachment_path_str) + if file_url: + import os + import aiohttp + from app.config import get_settings + from app.models.models import FileSyncQueue + settings = get_settings() + local_dir = settings.UPLOADS_DIR + os.makedirs(local_dir, exist_ok=True) + local_filename = f"conv_{conv.id}_{int(datetime.now().timestamp())}_{attachment_type}" + local_path = os.path.join(local_dir, local_filename) + async with aiohttp.ClientSession() as session: + async with session.get(file_url) as resp: + if resp.status == 200: + with open(local_path, "wb") as f: + f.write(await resp.read()) + sync_entry = FileSyncQueue( + local_path=local_path, + remote_path=f"/AegisOne_Service/bot/{local_filename}", + entity_type="conversation", + entity_id=conv.id, + status="pending", + ) + db.add(sync_entry) + except Exception: + pass + + gpt_key = SettingsCache.get("yandex_gpt_key") + if gpt_key: + cat_name = await categorize_with_gpt(text) + if cat_name: + from sqlalchemy import select + from app.models.models import BotCategory + result = await db.execute( + select(BotCategory).where( + BotCategory.name.ilike(f"%{cat_name}%"), + BotCategory.active == True + ).limit(1) + ) + cat = result.scalar_one_or_none() + if cat: + conv.inquiry_type = cat.id + await db.flush() + confirm_text = await get_template_rendered(db, "auto_categorize_confirm", category=cat.name) + from app.keyboards.inline import build_category_buttons + buttons = build_category_buttons() + await max_api.send_message_with_keyboard(max_user_id, confirm_text, buttons) + FSM.set_state(max_user_id, BotState.CATEGORY_SELECT) + return + else: + cat = await auto_categorize_text(db, text) + if cat: + conv.inquiry_type = cat.id + await db.flush() + + await db.flush() + + ooh = not SettingsCache.is_work_hours() + if ooh: + start = SettingsCache.get_int("work_hours_start", 9) + end = SettingsCache.get_int("work_hours_end", 18) + ooh_text = await get_template_rendered(db, "out_of_hours", + work_hours_start=start, work_hours_end=end) + await max_api.send_message(max_user_id, ooh_text) + + await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh) + + +async def _confirm_inquiry(bot, user_data, db, max_api, conv, is_ooh: bool): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + + if is_ooh: + text = await get_template_rendered(db, "confirmation_ooh", + first_name=user.first_name, conv_id=conv.id) + else: + text = await get_template_rendered(db, "confirmation", + first_name=user.first_name, conv_id=conv.id) + + await max_api.send_message(max_user_id, text, format="markdown") + + from app.integrations import _1c_unf as ic_integration + await ic_integration.send_to_1c(db, conv) + + FSM.set_state(max_user_id, BotState.CONFIRMATION) diff --git a/max_bot/app/handlers/main_menu.py b/max_bot/app/handlers/main_menu.py new file mode 100644 index 0000000..80bba2e --- /dev/null +++ b/max_bot/app/handlers/main_menu.py @@ -0,0 +1,103 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_or_create_user, get_template_rendered +from app.keyboards.inline import build_main_menu_buttons +from app.fsm import BotState, FSM + + +async def handle_main_menu(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + payload = user_data.get("payload", "") + + if payload == "new_inquiry": + user = await get_or_create_user(db, max_user_id) + if user.consent_given: + from app.handlers.contact import handle_contact_provided + await handle_contact_provided(bot, user_data, db, max_api) + else: + from app.handlers.consent import handle_consent_request + await handle_consent_request(bot, user_data, db, max_api) + return + + if payload == "handoff": + from app.handlers.handoff import handle_handoff_request + await handle_handoff_request(bot, user_data, db, max_api) + return + + if payload == "kb_search": + FSM.set_state(max_user_id, BotState.KB_SEARCH) + await max_api.send_message(max_user_id, "Задайте ваш вопрос, и я постараюсь найти ответ.") + return + + if payload.startswith("feature_"): + feature_key = payload.replace("feature_", "") + if SettingsCache.is_feature_enabled(feature_key): + await _handle_feature(bot, user_data, db, max_api, feature_key) + else: + await max_api.send_message(max_user_id, "Этот функционал временно недоступен.") + return + + if payload.startswith("cat_"): + await _handle_category_select(bot, user_data, db, max_api, payload) + return + + if payload == "cancel": + FSM.reset(max_user_id) + text = await get_template_rendered(db, "main_menu") + buttons = build_main_menu_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons) + return + + FSM.reset(max_user_id) + text = await get_template_rendered(db, "main_menu") + buttons = build_main_menu_buttons() + await max_api.send_message_with_keyboard(max_user_id, text, buttons) + + +async def _handle_feature(bot, user_data, db, max_api, feature_key: str): + feature_handlers = { + "risk_score": ("app.handlers.features.risk_score", "handle_risk_score"), + "sla_status": ("app.handlers.features.sla_status", "handle_sla_status"), + "photo_report": ("app.handlers.features.photo_report", "handle_photo_report"), + "appointment": ("app.handlers.features.appointment", "handle_appointment"), + "quality_rate": ("app.handlers.features.quality_rate", "handle_quality_rate"), + "commercial_offer": ("app.handlers.features.commercial", "handle_commercial"), + "my_objects": ("app.handlers.features.my_objects", "handle_my_objects"), + "reminders": ("app.handlers.features.reminders", "handle_reminders"), + "sla_ticket": ("app.handlers.features.sla_ticket", "handle_sla_ticket"), + "notifications": ("app.handlers.features.notifications", "handle_notifications"), + "broadcasts": ("app.handlers.features.broadcasts", "handle_broadcast_send"), + } + handler = feature_handlers.get(feature_key) + if handler: + import importlib + mod = importlib.import_module(handler[0]) + func = getattr(mod, handler[1]) + await func(bot, user_data, db, max_api) + else: + await max_api.send_message(max_user_id, "Функция в разработке.") + + +async def _handle_category_select(bot, user_data, db, max_api, payload: str): + max_user_id = user_data["user_id"] + try: + cat_id = int(payload.replace("cat_", "")) + except ValueError: + return + + from sqlalchemy import select + from app.models.models import BotCategory + result = await db.execute(select(BotCategory).where(BotCategory.id == cat_id)) + cat = result.scalar_one_or_none() + if not cat: + return + + from app.conversation import get_open_conversation, get_or_create_user + user = await get_or_create_user(db, max_user_id) + conv = await get_open_conversation(db, user.id) + if conv: + conv.inquiry_type = cat.id + await db.flush() + + ooh = not SettingsCache.is_work_hours() + from app.handlers.inquiry import _confirm_inquiry + await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh) diff --git a/max_bot/app/handlers/out_of_hours.py b/max_bot/app/handlers/out_of_hours.py new file mode 100644 index 0000000..4b726a7 --- /dev/null +++ b/max_bot/app/handlers/out_of_hours.py @@ -0,0 +1,43 @@ +from app.settings_cache import SettingsCache +from app.conversation import get_template_rendered +from app.keyboards.inline import build_cancel_button +from app.fsm import BotState, FSM +from app.handlers.main_menu import handle_main_menu + + +async def handle_out_of_hours(bot, user_data: dict, db, max_api): + max_user_id = user_data["user_id"] + await SettingsCache.refresh(db) + + start = SettingsCache.get_int("work_hours_start", 9) + end = SettingsCache.get_int("work_hours_end", 18) + hours_str = SettingsCache.get_work_hours_str() + + text = ( + f"Сейчас мы не работаем. Наше рабочее время: Пн-Пт {hours_str} (МСК).\n\n" + f"Оставьте сообщение — мы ответим как можно быстрее." + ) + buttons = [ + [{"type": "message", "text": "✉️ Оставить сообщение", "payload": "leave_message"}], + [{"type": "message", "text": "📞 Заказать звонок", "payload": "callback"}], + ] + await max_api.send_message_with_keyboard(max_user_id, text, buttons) + FSM.set_state(max_user_id, BotState.OUT_OF_HOURS) + + +async def handle_out_of_hours_callback(bot, user_data: dict, db, max_api, payload: str): + max_user_id = user_data["user_id"] + + if payload == "leave_message": + FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST) + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) + return + + if payload == "callback": + from app.handlers.consent import handle_consent_request + FSM.set_state(max_user_id, BotState.NAME_REQUEST) + await max_api.send_message(max_user_id, "Как к вам обращаться?") + return + + await handle_main_menu(bot, user_data, db, max_api) diff --git a/max_bot/app/integrations/_1c_unf.py b/max_bot/app/integrations/_1c_unf.py new file mode 100644 index 0000000..02aafc6 --- /dev/null +++ b/max_bot/app/integrations/_1c_unf.py @@ -0,0 +1,100 @@ +import json +import logging +from datetime import datetime +from typing import Optional +import aiohttp +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import BotConversation, Bot1CSyncLog, BotSetting +from app.settings_cache import SettingsCache + +logger = logging.getLogger(__name__) + + +async def send_to_1c(db: AsyncSession, conversation: BotConversation) -> Optional[dict]: + webhook_url = SettingsCache.get("1c_webhook_url") + if not webhook_url: + return None + + user = conversation.user + category = conversation.category + + payload = { + "conversation_id": conversation.id, + "user": { + "max_user_id": user.max_user_id, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "email": user.email, + }, + "inquiry": { + "text": conversation.inquiry_text, + "category": category.name if category else "", + "priority": conversation.priority, + "has_attachment": conversation.has_attachment, + "attachment_type": conversation.attachment_type, + }, + "created_at": conversation.created_at.isoformat() if conversation.created_at else "", + } + + sync_log = Bot1CSyncLog( + conversation_id=conversation.id, + status="pending", + request_payload=json.dumps(payload, ensure_ascii=False), + ) + db.add(sync_log) + await db.flush() + + try: + async with aiohttp.ClientSession() as session: + async with session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: + response_text = await resp.text() + sync_log.status = "sent" if resp.status == 200 else "error" + sync_log.response_payload = response_text + sync_log.sent_at = datetime.now() + await db.flush() + if resp.status == 200: + try: + return await resp.json() + except Exception: + return {"raw": response_text} + logger.warning(f"1C webhook returned {resp.status}: {response_text[:500]}") + return None + except Exception as e: + logger.error(f"1C webhook error: {e}") + sync_log.status = "error" + sync_log.response_payload = str(e) + sync_log.sent_at = datetime.now() + await db.flush() + return None + + +async def handle_1c_response(db: AsyncSession, data: dict) -> bool: + conversation_id = data.get("conversation_id") + if not conversation_id: + return False + + result = await db.execute(select(BotConversation).where(BotConversation.id == int(conversation_id))) + conv = result.scalar_one_or_none() + if not conv: + return False + + sync_log = Bot1CSyncLog( + conversation_id=conv.id, + status="confirmed", + response_payload=json.dumps(data, ensure_ascii=False), + confirmed_at=datetime.now(), + ) + db.add(sync_log) + + status = data.get("status", "") + if status: + conv.status = status + + assigned = data.get("assigned_to", "") + if assigned: + conv.assigned_to = assigned + + await db.flush() + return True diff --git a/max_bot/app/integrations/__init__.py b/max_bot/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da6d69a51c2963da15e9957b78488d0464043fae GIT binary patch literal 6306 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 literal 0 HcmV?d00001 diff --git a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fab329dadce4bd9f6c14d05a098f3bc307b50ada GIT binary patch literal 2592 zcmaJ@&5s;M6|d^9{+OPx-P!dzjzd_S@G%H&FqlY02tgJsBSntL2HDi5X-}1Rd)(7K zuC8IdRxcMLZY%j544MIilnehvF7Pk3#EFw{IVT|Tdp$GRjft31zkc5+xj z`EhxYj}CNMVs7Zx{kIFdU*x~mrcB7Z?f|&He*mL*io-g>=s;VW=7(6^U8&`MGCI;& z?WlWoXLND7Z?v76eB@9_t6v*;}HkxOz%7^L;bMs6XFWFtSkIGjZzKO<+R# zb}-(mJQb+$%u%f~_tdZ0e7o`&oHJ;b7VKwuqPG}lPx<4Y*q!<11-r4rp7IZ_od#7f zeyMUQ&+d$`s7STXxayp_Ynu-~J`JmI{_@(-X{!pyS5^0cV?I$m#M7_*_-fUHe8qlg z{_283`|{^^Uaxq{&0inC32%e7w>#_(bJ+LT&sPz>$Nv0<$N>|~?mmd%;V&F#L6vP( zt(5!hmtScAzdjGxU0A-j(~Dzza_6zUxPr#{y;UTRd55#ph_lCU+po;8sm&_-J^O=0 zd0*zlzE(v~`2Wkx9Yp^pL3=8A`b~5f98;-bvQL?VN!ci z_l}Y>POL@sPjqhU{<$98lc^qxx__}7Xj?)1%OK4k&QdswMn=Q2f*To3b*|7^H$UXE zqw{iRbexo{lZKVYXSl(=-N}W$+WE9M5)@lpUujn zI!Myu$l9sw?q?}tUXha&)1tI%@cre{%ocfig* zOY#JfyYpbdK;@#msj$X|AZ$;$wgvj;%2DFXvF&+h!IXCfdWO$#=`5p|*H`xM{ADaiAaR(adT?p9RJzJMZ{N8*N@A-Nj>;gL0H) ziHX4`abe;d_w%|pU!{dqpyrz-zD9z|*xVrTb%uxx*n)-1D95T}?u!kfwu z*C95AwiMSfTMv$sTxFW-Y+aSNfS2EbSXbBUJfvE3RyD-^fBSyqP<{C;iAwoC?1z_| zj+FuR4OnW~UOsq^1}-ejHy~tGKFpE~-vg7ACOvZJ9M}Vs?j_VZU{H^&H_;V)lS1jN zT-};m)k-PKOe*wd+i`M`E literal 0 HcmV?d00001 diff --git a/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc b/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de101136ec695ce6103392e095bb179c54a3bf2e GIT binary patch literal 163 zcmdPq_I|p@<2{{|u76<=|o!6PcKo zl3L-CS)3hHoLQ0@pO~7SS)8Aj8k3t?5ucP_5|da^5R;i#lA2zWSdy8aR~!=`pP83g m5+AQuP$ zU8a&71A?1_1s8CQ25?#!tqT}v4Wy`o9+E>2l_H=Gkb|~FL1Q5!hn{?spc-m`9{SIa zOG-53_Tr)gaOR(xe}?n;zwetJtgUe)NRPj$ogWbq`a7-Ii7Ohr^I$Ba%P5Q|P!Jo~ zM{RC`3-SifY<@xrIt)ipG{m6Oa0Vqq3c3uJ4~2zR6n39OQ!-3OPrdWhaSuG_Qlb&>6Aef zs0cHPe$|pDQgJPzrVL9uo7Iit+9~JEOVII~gJfVFSmzcF2?_^c7WQjyIvLqGY3H7vi-5|sP7TwDa z(_}$0bh?ZlYjpf%^pcWFsEgy8el@CVh8k1UIZaQe)aZh;7@JKSQ6-a!YAHjVBZ>jr z)}xs;F_dI1F*}r5vgBAyNu|<;0z+e@5vHX()BTVwqdg?y!5x}CcyF5)pPgmJ#yQrw z3r;9r+8I=66#N8!yYImQdyo(2Xh%`|%uoc66zvQbK_Pm66{&nS5pJY7^LZ3T^WeLP zm-&khH|j-BbW!L=QMyu=qLOVL=R}maM(@CfW>Cmux0lOkjcu3z%U)He&h9IhWv@_u zxz)BpUfU{{VLXFni1K5+J+EDcn#*3cb-8pVwinwk+o~tFMXXj&D zf`+i3O&U54;KPTzyGaA>Jp}8Bfd^nNHoFv~^V34WdV2nW1jpc`)|c*HyK`+z zZv5blTa$UY+myQ>wEVXHH|=@gk;St(-iXm(QE>`M=juoafn43uze-29UGNY8($B#1Zs4)g`2D_N^gH~D@Q@!y zAJK;2<6-oN(bvY_IJe9Q3W9z|?+|2m8ga$U}k9SZ6n3xr#>&=UNMm zMIIq7)PWvy023O@1rlL5!+_5+dQTD*gX|$+052U${E+GNY5=hX zz&29A>PaOAvQq>uc?BF;lq`cmq58<%eD~y?lUq{lPk)?~+Vhg%l>A$AYhL!7vj6@i zQyyZqE>rHhKVZs7cOAUTy(c1%cV*y%Q+aQ|^ak?YA=5jQ^S+vMzxGN0$Nit;KMw4= z98KaLa@2~;=eA`eH|9Nkrl&9GIlL(x-l>81uXU&T?_$<;~FqR>$ z9yGF`v;PaERnyZjs5+<}s-kvPQ1)Nqp^6UMc*yV({s?TJra1`K4m1SX_aAp(((V#k ztMN?9D@5a}B1QO6Q$-V08TXXOQIf4lVQxS7*#8eI|11x@R528)_P#?jUMiB^$2j6w z@G9zKJi!zC&X^?hm;$x?n1Y5)fhjtu)k(7w6%-x_o>EwYigTbK1K?yi3$m|Uypl}X zf4Q}q5dBwa!*i&J-f@YFoRo?n`{-q=_B|WNS@GovYkUqBwfx_Sel?j71MG%48-(_Q zz^(%*xo5=rIb`&_X8{9cPXUmYkj|(n%cG@Y8IqoZ$FV*caP31TC8M&27eEJ3_ZXoN zC$H1&WqL^m)VZCibKyl0GvK4OfU&~kk9-^4*}Yx?qXJ>ZEjcf>nNr)9Tz`9VW%5JK zl#fvR!peo)Q!7&+M(+P$v*UP99$`|w%U9@dH5BZVew%CQ!Z&XNe6 zj|)Gy;8VYW;e^wYR5Ya&eW!FPmNM(J(yhGItNhfjdTFl;(x3{{kP^W}Bs}i^$+*Y; z-`zBZNMHE82a$k>5EQD4j&uu z;X=rz@ukoUSx+3CW%D3_K74<}rzDtP_cU42r}T-lA}!U<%2_*i0mnDwM{-D9qM3H@ zJ4cZ$DZv+7dD_>`Q`+{lGYi^~(-r*#Q2xc%bWPicJ6LvOJYg%=#%tn}pJBA0U|iTX zJ9t5_Kc_Hx(l@Mt4X~-u5|?J8AM$b`zLv6)CcL+tZ`_`EEG7#rB<{s{zdR697`M(V zVZ5R&j-V)us?cVnCV835tf@uT$gGAkab*a-Bq}5IoAE3wII>ZTQa>Z|T?oIpn$PDq zVI^WJa}DIx&3PkrUS@o}KVO>5u)Yj>!bxP~Wrp+O=miIj5w!z`P=^NeA{|kee*8LO z@(RQfW@k3xrfb1v4B=A;!XVIt&mPEE-~eEv;UEAo1HkTz9yw5Y9LlxR0{U664b}uu zho?}((Y4XK18jWIN%2U?0gRZ$as-8CCq4AuLbC@3l&5pOP-)OeE+lL&jV4m$TJS8_ zsmq0$$V=Gz9Wc+j{n(!JGDZZW^hFWnNAmh-zt*A-(h_ zurD6#{TwgfqLG-C=`P#}`&Gk5sWyc@ye@<6>M?I69=BmiOPlf%r?;XuZdpJSDN9_y hiH+DT*@OwcfC(0*_!4hJg`(=yKHX!ZcodBW{{zafl^Fm4 literal 0 HcmV?d00001 diff --git a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1291fde0afe56778a591d35fb017d14f0750840d GIT binary patch literal 6100 zcmds5eQaCR6~EWdet!Op6UT8H=i?vrn~7@4Ud57HqnKdn&Lk_Qc)G~1S0fpHT-?_kMG4#+XIRj1zDw{7HsKzLj{*cXgF zJQ54aqCX%`24j)1I5HhL=-(faM*`94NH8pmlTttqM#8a?8EuAtGAg%4XH=`-9|(sd zascM~B^&6)5XCBh%#s_Vp4zV=`FU>!tJiw9)^!*q+Mkp zMqp*xGQ{S&LS0KbbB*oP&%||c#;?ow_^9B12z)z8XPH5RnM94l#v$mxg z6(q5I0X^I>H>916?2J4eUGDxaY`C9j(cy;hrQTL$_k51NGC`NuHdK_?RH*a*$05>!8$X>nuYJ(IHP$uci^$ZI z5l3G8kTcH}YN2xdNaLQ$@5~yAy$^4vk_2@%qzXm9@x`->acPF1`nf#ZM^awnTf%+)+fUcLo)x%)x zC}>lI4o(A*Bm-s z(5amJRIro|Kwqr_`qp@vXZzwd~ z7KjGh#zK+!cw6?>wS`65I}wS8$F-a1{>Zo(ig_Pt_l`xTqahJ*Bh|JuE>A_I;2}+9 zjoTRww%#Ys@N3rI2wYlWxpm)6R9thwEfxxnX+qwI;C@@P9Bj?*j8@ng+%)R`wiH}r z;aUTC*2=tDAE4P9f_u>$f(tGttHlM|-;8Sap~}M5Dh8(eRf{Z6M@6_MFOKtv%5ED06A@_}-)NQaCr)K1MOifl zf)Nb8YU>RJAxe9}F$||Z8i7T^ieM2jX(P%TV0po)%7!A7lcJ;=4+Nwz02kG?>)@EE zg+MiECQe$krKm=TnRWtAg`>wtC3t7D%EYCR)PQRB`y=Bssxg)~46BW*dU(?Tm5MP7 zh-~C<770KbQJsb5`VR!Juspnjt}5$e+aPQUArf3ueofmQTRJo?CrC@cO6w} zN9|=tty0&y;^>;){hp=d<;JT;#Tiy_YRYh=**jO3G3ib18HN}v83(Zp(%IhkIrs7I z6TTBY%7)feu1qO!TIF2FW->;75j*S4Sc%DbY~uI>$L~`%-K~_~v&wmnPt6(T9$Of@ zxLp|%l&Za}TnR`_oS2xKP+VO~={I1mm7VR&SUJO%cbye!=jO}K&1q*7e6Kj0<|bF& z)z3fr%%dlI7vy7)rrmw;z2fd$)~`C<&u@Qb`-%E1&W6?6hF@%WWy7nUw5R1*->RqP zRy;E|jHG-Q1%oMf-wlS8HiJ)>olPH^NwNER`!jZ>wrAmv3(ape zFH|WtzAKJ_8>ZZZy!15(vD*Jq3{$VV`~K)@xn@9tYpClzZ7HDqvb%3BFAoM%&L(Zv z#~&Gp)%6jf5Ejn`cMZ7dg=Qdc zH`n?$l1ozF~~c3pUH6PPTF5l{ z`y0$lj2?NT4JI#@(*8~Cl1q>L1_}n2D(XOf=`IV%B#gAbkxOtWpRig$CgG+&qdrlN z@`*|v$S0~P^0gFY>M6`kG-&)Lt-n#@n^~XUmT0H_oh6A5GxB%qknZ4MR?>))NfYa< zHzkXxuf(2on34DBkXE6sWG&iC*5iVc8!ZE+OtQUvpom#EvCzL^cUuq#4 z;HXImI~%El2Q-^SXlGLc{u*h+fjs)JqpoWN$ZO6FR&P4BmShK+_0j(xz$RaF^l$z@ zLI1CQt~`o^^qv>t6znt^#oExd|M%%d#(SRfH036*KaP(@x5qJQOJsNOqN8s29jU~ngLQ4+Xzs0;%tViBu^L3J? zI@;gGF4gOi-$cQ{(&jplPnazrlW@`gtz4oE!B(kC{ee4}9CQ_fSBuUJ`CoMXpHV$SbT_~A!v%YqKiPXp2lhtPA8+AyV z(N@xnwvvF0f$y{o)G^5&H=4AwsTwiy!-TH{a_F{L?r&@V z82)da%_vQfQ4VIaL(cCH2d43#VC~3aawg%SJ4rOqm(An2yCB_;hs1lN-7o|vk;L$q sCBslke^2c2{R3%v-{eS}DlVHUj(4w^?wDo&%EI5rHwG!dNKMs00JG)=a{vGU literal 0 HcmV?d00001 diff --git a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c8dc0a7bb73fc165663359ae1ffcec1c9f0f3e3 GIT binary patch literal 2469 zcmcgu+iw(A7(a8_-PztO1qwyPQRAgbyK2;!hKNWc5+gXR*6TY4kW#OT}X>%974`Y$x`^802NTDkaOjI)_<&UgR5bLRWa z9&2o@W4ONgrK$g1J!8KTv-#4%yp7v`6NEFaxXhxb>M8}*QWYjNp>b_O`1#3H+G94aEf{VpxxUi+SOPO#9$Dor3lKh*sG9T?V`dOaQ}$g z-v>>=CKawsDI-dEY!-oz$k-YwR%Ee2vR07h81}7z1@*D)J45(eG1MRF5;-oA$ONanq1phd-<#UKvAi6|WFeVnZGbWbvW@R+G1LY`1=rW_r5K_=pxXeVe(Ng7N!XrR5x7UoO zqVecvbOo!gVe>u0pC(HLM~DTaCpStm58*k=y4O5>v;%^b%Gsox(aM?1>B?BNi02qd z5bHWh9YwA&L|lk&A;jH$<5Wpy6q=>Vd8+zu(CA7M1^q^IornP4t!NH0&zo4Tk~vyh zHIstoH)2N14{TPnfD&fW^t_3^Sgl+_Ei2Vci*6(K4XhVY!G2p_=7VN>J-he3j zv0nCtoK`JedajTJG^ocYP!GqXV{O^!;lmPj;zKEX-_8qvT8%S(!p2*RFx1J~&u}Lc z@yl)yH`s01}aR`B>m8C0N;cn@ld3?yq_|*jH zTll3|$I;o-Og;yT^*uPDoP!g{W4oy3+FsMhc9w?XtvEc9r{fYmr<|aVVr7b=97x+O{{-###VWo5E5-o=)nrS}?(#e(gU_ qPcQw+sbLMUBbhi!=9{WY>Pt#9_5u9Lu6T=fz^50+P#U!6Xa53^>t7uJ literal 0 HcmV?d00001 diff --git a/max_bot/app/integrations/portal_db.py b/max_bot/app/integrations/portal_db.py new file mode 100644 index 0000000..dcd74f4 --- /dev/null +++ b/max_bot/app/integrations/portal_db.py @@ -0,0 +1,57 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import Object, SLAContract, Task, Incident, Customer + + +async def get_object_by_name_or_id(db: AsyncSession, query: str) -> Object: + try: + obj_id = int(query) + return await db.get(Object, obj_id) + except (ValueError, TypeError): + result = await db.execute( + select(Object).where(Object.name.ilike(f"%{query}%")).limit(1) + ) + return result.scalar_one_or_none() + + +async def get_sla_for_object(db: AsyncSession, object_id: int) -> SLAContract: + result = await db.execute( + select(SLAContract).where( + SLAContract.object_id == object_id, + SLAContract.status == "active" + ).order_by(SLAContract.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() + + +async def get_objects_for_customer(db: AsyncSession, customer_name: str) -> list: + result = await db.execute( + select(Customer).where(Customer.name.ilike(f"%{customer_name}%")).limit(1) + ) + customer = result.scalar_one_or_none() + if not customer: + return [] + result = await db.execute( + select(Object).where(Object.customer_id == customer.id, Object.status == "active") + ) + return result.scalars().all() + + +async def get_active_tasks_for_object(db: AsyncSession, object_id: int) -> list: + result = await db.execute( + select(Task).where( + Task.object_id == object_id, + Task.status.in_(["open", "in_progress"]) + ) + ) + return result.scalars().all() + + +async def get_last_incident_for_object(db: AsyncSession, object_id: int): + result = await db.execute( + select(Incident).where( + Incident.object_id == object_id, + Incident.status.in_(["resolved", "closed"]) + ).order_by(Incident.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() diff --git a/max_bot/app/integrations/yandex_gpt.py b/max_bot/app/integrations/yandex_gpt.py new file mode 100644 index 0000000..86e682f --- /dev/null +++ b/max_bot/app/integrations/yandex_gpt.py @@ -0,0 +1,80 @@ +import aiohttp +import logging +from app.settings_cache import SettingsCache + +logger = logging.getLogger(__name__) + + +async def categorize_with_gpt(text: str) -> str: + gpt_key = SettingsCache.get("yandex_gpt_key") + folder_id = SettingsCache.get("yandex_folder_id") + if not gpt_key or not folder_id: + return "" + + prompt = ( + "Определи категорию обращения клиента из списка: " + "Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, " + "Обслуживание, Консультация, Другое. " + "Ответь только названием категории, без пояснений. " + f"Текст обращения: {text}" + ) + + url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + headers = { + "Authorization": f"Api-Key {gpt_key}", + "Content-Type": "application/json", + "x-folder-id": folder_id, + } + body = { + "modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest", + "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 50}, + "messages": [{"role": "user", "text": prompt}], + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip() + logger.warning(f"GPT categorize returned {resp.status}") + except Exception as e: + logger.error(f"GPT categorize error: {e}") + return "" + + +async def analyze_emotion_with_gpt(text: str) -> str: + gpt_key = SettingsCache.get("yandex_gpt_key") + folder_id = SettingsCache.get("yandex_folder_id") + if not gpt_key or not folder_id: + return "neutral" + + prompt = ( + "Определи эмоциональную окраску текста клиента. " + "Варианты: positive, neutral, negative. " + "Ответь только одним словом. " + f"Текст: {text}" + ) + + url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + headers = { + "Authorization": f"Api-Key {gpt_key}", + "Content-Type": "application/json", + "x-folder-id": folder_id, + } + body = { + "modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest", + "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 10}, + "messages": [{"role": "user", "text": prompt}], + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip().lower() + logger.warning(f"GPT emotion analysis returned {resp.status}") + except Exception as e: + logger.error(f"GPT emotion analysis error: {e}") + return "neutral" diff --git a/max_bot/app/keyboards/__init__.py b/max_bot/app/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e67fec3e723bb574826fcc9e81b9792c5c4ca060 GIT binary patch literal 160 zcmdPq_I|p@<2{{|u76W$R)U6PcKo zl3L-CS)3hHoLQ0@pO~7SS)8Aj8k3t?5ucP_5|da^5R;u+nUtSclu{fMAD@|*SrQ+w jS5SG2!zMRBr8Fniu80+A8pyt45aSawBO_xGGmr%Ut2ida literal 0 HcmV?d00001 diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4dd1a2d2f03b42594bc13cdc0ca52c698b3a310 GIT binary patch literal 5998 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 literal 0 HcmV?d00001 diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67ae683527c23a3126509205eca6587b6a96bb5b GIT binary patch literal 3149 zcmb7GOKjZ68RiVh-PKB3IkJ+}eKG-Bc!An(lN1PyzJe453Isw?qZgtD!Cj75l(^(- zNG6rR1!_AFG->M=2~Z%B9jPb^_)tJ`t<-Q5`Q8)v%AW)^g0-|#9Z}qZ)w}~PIwPsw(Tun(7Gqz z$NpNE6^j={lK9~ue%kG?2~0GkNKzU0X?^;%(4p^XeDT|82HF^B_c)z*TSpHkrQU## zS*Go18QXh~X6nnW zn|fg+TO)y1{3@{n0*~$;{d-?w4*EE|S;D3<6 zYd6>2(2Fi!?DBM}WPjQEnICUh7y6MD_VPvH!VJQZm~[LcE?^0nVt7oK?vlIhgg z=<4KhO&?RuhdM#Ehp-&i+&P!n|8ZK+-}!s+6%KV z+4D1M+Y7U5*eB{UI54n~cDvr-eU3AwUqN$t{nZlT+oiV_R2scjtJdeAUvlsLR^@BB8G9dRWy5Pl0;!#=rVfFZcPd7rK!EDb$fm~ zF?2_Tz9F`3GjMw%kQC@QHA!B1^e}clhqYehN-sX=hk+l8v!kuz;a=(oo-=g)(1Gbw zrxI&`EB+Q5jTyYn+MgOmo43&CZ07x_F}K26Y=s+anRR%FwYhu%JohCX5@O<0N$1Px z)Cp(6W>O9taD;YqRnp$+m(Uz;!+Bo=tiMN)0Mx(3TlUZ+Pg(2(9Qdj;@Shc-qzzJw zc&@|8uak$7J^l;h(v(q(i)k!81<>*41SAy?me@?`D`dWorC1dQ-u{g1_&$}(nERa0 zIwqCQ9dt^axA}k#7_>B2?IkHTQxPW)vtG1&-e~UcGuVSH#CpqZ@i&h*F7CQ6Fol7`AH+=S5aLyOu3Swnq-_ZK1teK z$+%pv=Q4t)Q98j9vuva9CXO<%oWnu7%`$pA6g^lHgGl-!mShmS?E_0k45M|wFhX}I z3hsM_9*D4udO1VA0BVV{t`$0FXK+)HJyhAfl)g2^>~YY?524ZE@&?`(JIxkYn>EmH zGkFRt_n}CnWFdv(d30*WuiYL!s;E?Rss2)BMISw?uQbb5SS+pX3!x zY&R&~I^QmxpLW7zJ^xrrfR$7A?BgjZ|DV}<;R#j0W*6$U@613{d)lmjO*3Z|Bu`=+cyI90Vp68e1= zJH&{GkSC~>27g6|G@$$7*kBsntTIi-f{8m1$Nj3gOHtFR{&Us8O@Bo<538#iWX38! zM9lxew~&=`7U}PGG9%Nkk|e`Zk_Y&5sIjOa8mYqcn7^GW%~)DVS!NoaKLtxnEE-$j zPqHW3qn{Y2JdN>ccivt`1w0#9cVuJ^T#{8LA*JI)RV|gqRWekutXx&OMWKU2NjmOc gDg;{J9Y$Uni0>1cF=hJ&z4J(?)oFA(o%xROZx33;i~s-t literal 0 HcmV?d00001 diff --git a/max_bot/app/keyboards/inline.py b/max_bot/app/keyboards/inline.py new file mode 100644 index 0000000..c18e6ee --- /dev/null +++ b/max_bot/app/keyboards/inline.py @@ -0,0 +1,90 @@ +from app.settings_cache import SettingsCache + + +def build_main_menu_buttons() -> list: + buttons = [] + row = [ + {"type": "message", "text": "📋 Оставить заявку", "payload": "new_inquiry"}, + {"type": "message", "text": "📞 Связаться с оператором", "payload": "handoff"}, + ] + buttons.append(row) + + features = SettingsCache.get_enabled_features() + feature_buttons = [] + feature_map = { + "risk_score": ("📊 Risk Score", "feature_risk_score"), + "sla_status": ("🔍 SLA статус", "feature_sla_status"), + "photo_report": ("📸 Фото-отчёт", "feature_photo_report"), + "appointment": ("📅 Запись на визит", "feature_appointment"), + "quality_rate": ("⭐ Оценка качества", "feature_quality_rate"), + "commercial_offer": ("📄 Коммерческое предложение", "feature_commercial"), + "my_objects": ("🏢 Мои объекты", "feature_my_objects"), + "reminders": ("🔔 Напоминания", "feature_reminders"), + "notifications": ("🔔 Уведомления", "feature_notifications"), + "broadcasts": ("📢 Рассылки", "feature_broadcast"), + } + for key, (label, payload) in feature_map.items(): + if key in features: + feature_buttons.append({"type": "message", "text": label, "payload": payload}) + + while feature_buttons: + buttons.append(feature_buttons[:2]) + feature_buttons = feature_buttons[2:] + + row = [{"type": "message", "text": "❓ Задать вопрос", "payload": "kb_search"}] + buttons.append(row) + + return buttons + + +def build_consent_buttons() -> list: + return [[ + {"type": "message", "text": "✅ Даю согласие", "payload": "consent_given"}, + ], [ + {"type": "message", "text": "❌ Не даю согласие", "payload": "consent_refused"}, + ]] + + +def build_contact_buttons() -> list: + return [[ + {"type": "request_contact", "text": "📱 Поделиться контактом"}, + ]] + + +def build_category_buttons() -> list: + categories = SettingsCache.get_categories() + buttons = [] + row = [] + for cat in categories: + row.append({"type": "message", "text": f"{cat.icon_emoji} {cat.name}", "payload": f"cat_{cat.id}"}) + if len(row) >= 2: + buttons.append(row) + row = [] + if row: + buttons.append(row) + return buttons + + +def build_quality_buttons() -> list: + return [[ + {"type": "callback", "text": "⭐", "payload": "rate_1"}, + {"type": "callback", "text": "⭐⭐", "payload": "rate_2"}, + {"type": "callback", "text": "⭐⭐⭐", "payload": "rate_3"}, + ], [ + {"type": "callback", "text": "⭐⭐⭐⭐", "payload": "rate_4"}, + {"type": "callback", "text": "⭐⭐⭐⭐⭐", "payload": "rate_5"}, + ]] + + +def build_cancel_button() -> list: + return [[{"type": "message", "text": "↩️ Отмена", "payload": "cancel"}]] + + +def build_phone_email_buttons(phone_1: str, phone_2: str, email: str, subject: str) -> list: + from urllib.parse import quote + buttons = [ + [{"type": "link", "text": f"📞 {phone_1}", "url": f"tel:{phone_1.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}], + [{"type": "link", "text": f"📞 {phone_2}", "url": f"tel:{phone_2.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}], + [{"type": "link", "text": f"✉️ {email}", "url": f"mailto:{email}?subject={quote(subject)}"}], + ] + return buttons diff --git a/max_bot/app/main.py b/max_bot/app/main.py new file mode 100644 index 0000000..69e4dc3 --- /dev/null +++ b/max_bot/app/main.py @@ -0,0 +1,879 @@ +import json +import logging +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Optional + +from fastapi import FastAPI, Request, Depends, Query +from fastapi.responses import JSONResponse, HTMLResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ + +from app.config import get_settings +from app.database import get_db, async_session, engine +from app.models.models import Base +from app.max_api import MaxAPIClient +from app.settings_cache import SettingsCache +from app.bot_engine import handle_update +from app.text_renderer import render_template, make_clickable_links + +settings = get_settings() +logger = logging.getLogger(__name__) + +max_api: Optional[MaxAPIClient] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global max_api + async with engine.begin() as conn: + pass + + async with async_session() as db: + await SettingsCache.refresh(db) + + token = SettingsCache.get_bot_token() + if token: + max_api = MaxAPIClient(token) + try: + me = await max_api.get_me() + logger.info(f"Bot connected: {me}") + except Exception as e: + logger.error(f"Bot connection failed: {e}") + else: + logger.warning("No bot token configured") + + yield + + if max_api: + await max_api.close() + + +app = FastAPI(title="AegisOne Max Bot — София", lifespan=lifespan) + + +@app.post(settings.WEBHOOK_PATH) +async def webhook(request: Request, db: AsyncSession = Depends(get_db)): + secret_header = request.headers.get("X-Max-Bot-Api-Secret", "") + webhook_secret = settings.WEBHOOK_SECRET + if webhook_secret and secret_header != webhook_secret: + logger.warning("Invalid webhook secret") + return JSONResponse({"error": "Invalid secret"}, status_code=403) + + raw = await request.body() + try: + body = json.loads(raw) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON from MAX: {e}, raw={raw[:500]}") + return JSONResponse({"ok": False}, status_code=200) + + logger.info(f"Webhook update: {body.get('update_type', 'unknown')}") + + global max_api + if not max_api: + token = SettingsCache.get_bot_token() + if token: + max_api = MaxAPIClient(token) + + try: + await handle_update(None, body, db, max_api) + await db.commit() + except Exception as e: + logger.error(f"Webhook handler error: {e}", exc_info=True) + await db.rollback() + + return JSONResponse({"ok": True}) + + +@app.get("/api/1c/response") +@app.post("/api/1c/response") +async def api_1c_response(request: Request, db: AsyncSession = Depends(get_db)): + try: + body = await request.json() + from app.integrations import _1c_unf as ic_integration + success = await ic_integration.handle_1c_response(db, body) + await db.commit() + if success: + conv_id = body.get("conversation_id") + user_id = body.get("user_id") + if user_id and max_api: + status = body.get("status", "в работе") + engineer = body.get("engineer", "") + from app.conversation import get_template_rendered + text = await get_template_rendered(db, "status_update", + conv_id=conv_id, status=status, engineer=engineer) + await max_api.send_message(user_id, text, format="markdown") + return JSONResponse({"ok": success}) + except Exception as e: + logger.error(f"1C response error: {e}", exc_info=True) + await db.rollback() + return JSONResponse({"ok": False, "error": str(e)}, status_code=500) + + +@app.get("/health") +async def health(): + return {"status": "ok", "bot": "София"} + + +# ===================== Service Portal API ===================== + +@app.get("/api/bot/settings") +async def api_bot_settings(db: AsyncSession = Depends(get_db)): + await SettingsCache.refresh(db) + return JSONResponse(SettingsCache.get_all()) + + +@app.post("/api/bot/settings/save") +async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotSetting + body = await request.json() + for key, value in body.items(): + result = await db.execute(select(BotSetting).where(BotSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = str(value) + else: + db.add(BotSetting(key=key, value=str(value))) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/templates") +async def api_bot_templates(db: AsyncSession = Depends(get_db)): + await SettingsCache.refresh(db) + return JSONResponse(SettingsCache.get_all_templates()) + + +@app.post("/api/bot/templates/save") +async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotResponseTemplate + body = await request.json() + for key, value in body.items(): + result = await db.execute(select(BotResponseTemplate).where(BotResponseTemplate.template_key == key)) + tmpl = result.scalar_one_or_none() + if tmpl: + tmpl.template_text = value + else: + db.add(BotResponseTemplate(template_key=key, template_text=value)) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/features") +async def api_bot_features(db: AsyncSession = Depends(get_db)): + from app.models.models import BotFeature + result = await db.execute(select(BotFeature).order_by(BotFeature.sort_order)) + features = result.scalars().all() + return JSONResponse([ + {"key": f.feature_key, "name": f.name, "description": f.description, "enabled": f.enabled} + for f in features + ]) + + +@app.post("/api/bot/features/toggle") +async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotFeature + body = await request.json() + key = body.get("feature_key") + enabled = body.get("enabled", False) + result = await db.execute(select(BotFeature).where(BotFeature.feature_key == key)) + feature = result.scalar_one_or_none() + if feature: + feature.enabled = enabled + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/categories") +async def api_bot_categories(db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + result = await db.execute(select(BotCategory).order_by(BotCategory.sort_order)) + cats = result.scalars().all() + return JSONResponse([ + {"id": c.id, "name": c.name, "description": c.description, "active": c.active, + "icon_emoji": c.icon_emoji, "sort_order": c.sort_order, "keywords": c.keywords} + for c in cats + ]) + + +@app.post("/api/bot/categories/create") +async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + db.add(BotCategory( + name=body.get("name", ""), description=body.get("description", ""), + active=body.get("active", True), icon_emoji=body.get("icon_emoji", "📋"), + sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []), + )) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/categories/edit") +async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id"))) + cat = result.scalar_one_or_none() + if cat: + for f in ("name", "description", "active", "icon_emoji", "sort_order", "keywords"): + if f in body: + setattr(cat, f, body[f]) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/categories/delete") +async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id"))) + cat = result.scalar_one_or_none() + if cat: + await db.delete(cat) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/kb") +async def api_bot_kb(db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + result = await db.execute(select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order)) + entries = result.scalars().all() + return JSONResponse([ + {"id": e.id, "question": e.question, "answer": e.answer, "category": e.category, + "active": e.active, "sort_order": e.sort_order, "keywords": e.keywords} + for e in entries + ]) + + +@app.post("/api/bot/kb/create") +async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + db.add(BotKnowledgeBase( + question=body.get("question", ""), answer=body.get("answer", ""), + category=body.get("category", ""), active=body.get("active", True), + sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []), + )) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/kb/edit") +async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id"))) + entry = result.scalar_one_or_none() + if entry: + for f in ("question", "answer", "category", "active", "sort_order", "keywords"): + if f in body: + setattr(entry, f, body[f]) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/kb/delete") +async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id"))) + entry = result.scalar_one_or_none() + if entry: + await db.delete(entry) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/conversations") +async def api_bot_conversations(db: AsyncSession = Depends(get_db), + status: str = Query(None), + page: int = Query(1), + limit: int = Query(20)): + from app.models.models import BotConversation, BotUser + query = select(BotConversation, BotUser.first_name).join(BotUser) + if status: + query = query.where(BotConversation.status == status) + query = query.order_by(BotConversation.created_at.desc()).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + {"id": c.id, "user_name": fn, "status": c.status, "inquiry_text": c.inquiry_text[:100], + "created_at": c.created_at.isoformat() if c.created_at else "", "priority": c.priority} + for c, fn in rows + ]) + + +@app.get("/api/bot/users") +async def api_bot_users(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(20), + sort_by: str = Query("created_at"), + sort_dir: str = Query("desc"), + consent: str = Query(None)): + from app.models.models import BotUser + allowed_sort = {"id", "max_user_id", "first_name", "last_name", "phone", "email", "consent_given", "created_at"} + if sort_by not in allowed_sort: + sort_by = "created_at" + sort_col = getattr(BotUser, sort_by) + order = sort_col.desc() if sort_dir == "desc" else sort_col.asc() + + filters = [] + if consent == "true": + filters.append(BotUser.consent_given == True) + elif consent == "false": + filters.append(BotUser.consent_given == False) + + count_q = select(func.count(BotUser.id)) + if filters: + count_q = count_q.where(and_(*filters)) + total = (await db.execute(count_q)).scalar() or 0 + + query = select(BotUser).where(*filters).order_by(order).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + return JSONResponse({ + "total": total, + "users": [ + {"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name, + "last_name": u.last_name, "phone": u.phone, "email": u.email, + "consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""} + for u in users + ], + }) + + +@app.get("/api/bot/analytics") +async def api_bot_analytics(db: AsyncSession = Depends(get_db), + days: int = Query(30)): + from app.models.models import BotConversation, BotUser + from datetime import timedelta + since = datetime.now() - timedelta(days=days) + + total_consented = await db.execute( + select(func.count(BotUser.id)).where(BotUser.consent_given == True)) + total_convs = await db.execute( + select(func.count(BotConversation.id)).where(BotConversation.created_at >= since)) + open_convs = await db.execute(select(func.count(BotConversation.id)).where( + BotConversation.status == "open", BotConversation.created_at >= since)) + closed_convs = await db.execute(select(func.count(BotConversation.id)).where( + BotConversation.status == "closed", BotConversation.created_at >= since)) + + return JSONResponse({ + "total_users": total_consented.scalar(), + "conversations_period": total_convs.scalar(), + "open_conversations": open_convs.scalar(), + "closed_conversations": closed_convs.scalar(), + "period_days": days, + }) + + +@app.post("/api/bot/test-run") +async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db)): + body = await request.json() + scenario = body.get("scenario", "new_dialog") + messages = body.get("messages", []) + + settings = SettingsCache.get_all() + features = {k: v.enabled for k, v in SettingsCache.get_enabled_features().items()} + categories = [{"id": c.id, "name": c.name} for c in SettingsCache.get_categories()] + + test_api = MaxAPIClient("test_token", test_mode=True) + conversation_log = [] + + try: + from app.fsm import FSM, BotState + from app.conversation import get_or_create_user, add_message + await SettingsCache.refresh(db) + + mock_user_id = body.get("mock_user_id", 999999999) + user = await get_or_create_user(db, mock_user_id) + if not user: + user = await get_or_create_user(db, mock_user_id) + FSM.set_state(mock_user_id, BotState.IDLE) + + if scenario == "new_dialog": + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + elif scenario == "out_of_hours": + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + elif scenario == "returning": + conv = await get_or_create_user(db, mock_user_id) + user = await get_or_create_user(db, mock_user_id) + from app.conversation import create_conversation + from app.models.models import BotConversation + existing = await db.execute( + select(BotConversation).where(BotConversation.user_id == user.id).order_by(BotConversation.created_at.desc()) + ) + if not existing.scalar_one_or_none(): + await create_conversation(db, user.id, "open", "Test inquiry") + await db.commit() + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + + for msg_text in messages: + update = { + "update_type": "message_created", + "user": {"user_id": mock_user_id}, + "message": { + "body": {"text": msg_text}, + "attachments": [], + }, + } + await handle_update(None, update, db, test_api) + conversation_log.append({"role": "user", "text": msg_text}) + for bot_msg in test_api.test_messages: + conversation_log.append({"role": "bot", "text": bot_msg["text"]}) + test_api.test_messages.clear() + + await db.rollback() + FSM.clear_all() + + except Exception as e: + logger.error(f"Test-run error: {e}", exc_info=True) + await db.rollback() + + return JSONResponse({ + "scenario": scenario, + "conversation": conversation_log, + "settings": settings, + "enabled_features": features, + "categories": categories, + }) + + +@app.get("/api/bot/broadcast/recipients") +async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), + q: str = Query(None), + page: int = Query(1), limit: int = Query(200)): + from app.models.models import BotUser + filters = [BotUser.consent_given == True] + if q: + like = f"%{q}%" + filters.append( + BotUser.first_name.ilike(like) | + BotUser.last_name.ilike(like) | + BotUser.phone.ilike(like) | + BotUser.email.ilike(like) + ) + count_q = select(func.count(BotUser.id)).where(and_(*filters)) + total = (await db.execute(count_q)).scalar() or 0 + query = select(BotUser).where(and_(*filters)).order_by(BotUser.first_name).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + return JSONResponse({ + "total": total, + "users": [ + {"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name, + "last_name": u.last_name, "phone": u.phone, "email": u.email, + "consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""} + for u in users + ], + }) + + +@app.post("/api/bot/broadcast") +async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotBroadcast, BotUser + body = await request.json() + text = body.get("text", "") + if not text: + return JSONResponse({"error": "Text is required"}, status_code=400) + + action = body.get("action", "send") + user_ids = body.get("user_ids", None) # None or [] = all consenting users + draft_id = body.get("draft_id") + + # Build recipient list: only consenting users + filters = [BotUser.consent_given == True] + if user_ids is not None and len(user_ids) > 0: + filters.append(BotUser.max_user_id.in_(user_ids)) + + result = await db.execute(select(BotUser.max_user_id, BotUser.first_name, BotUser.last_name).where(and_(*filters))) + recipients = result.all() + recipient_ids = [r[0] for r in recipients] + recipient_names = [f"{r[1]} {r[2]}".strip() or f"id{r[0]}" for r in recipients] + + if action == "preview": + # Create or reuse draft + if draft_id: + result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id)) + broadcast = result.scalar_one_or_none() + if not broadcast: + return JSONResponse({"error": "Draft not found"}, status_code=404) + broadcast.text = text + broadcast.recipient_count = len(recipient_ids) + broadcast.recipient_ids = recipient_ids + else: + broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids), + recipient_ids=recipient_ids, status="draft") + db.add(broadcast) + await db.commit() + return JSONResponse({ + "draft_id": broadcast.id, + "text": text, + "recipient_count": len(recipient_ids), + "recipients": recipient_names, + }) + + # action == "send" + if draft_id: + result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id)) + broadcast = result.scalar_one_or_none() + if broadcast: + broadcast.text = text + broadcast.recipient_count = len(recipient_ids) + broadcast.recipient_ids = recipient_ids + broadcast.status = "sent" + broadcast.sent_at = datetime.now() + else: + return JSONResponse({"error": "Draft not found"}, status_code=404) + else: + broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids), + recipient_ids=recipient_ids, status="sent", sent_at=datetime.now()) + db.add(broadcast) + await db.commit() + + sent_count = 0 + if max_api: + for uid in recipient_ids: + try: + await max_api.send_message(uid, text, format="markdown") + sent_count += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "sent_to": sent_count, "total_recipients": len(recipient_ids)}) + + +@app.get("/api/bot/broadcast/history") +async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(20)): + from app.models.models import BotBroadcast + count_q = select(func.count(BotBroadcast.id)).where(BotBroadcast.status == "sent") + total = (await db.execute(count_q)).scalar() or 0 + query = select(BotBroadcast).where(BotBroadcast.status == "sent").order_by( + BotBroadcast.sent_at.desc()).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + broadcasts = result.scalars().all() + return JSONResponse({ + "total": total, + "broadcasts": [ + {"id": b.id, "text": b.text[:200], "recipient_count": b.recipient_count, + "sent_at": b.sent_at.isoformat() if b.sent_at else ""} + for b in broadcasts + ], + }) + + +# ===================== Risk Questions API ===================== + +@app.get("/api/bot/risk-questions") +async def api_bot_risk_questions(db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + result = await db.execute( + select(BotRiskQuestion).order_by(BotRiskQuestion.sort_order) + ) + questions = result.scalars().all() + return JSONResponse([ + { + "id": q.id, "question_key": q.question_key, + "question": q.question, "weight": q.weight, + "sort_order": q.sort_order, "is_active": q.is_active, + } + for q in questions + ]) + + +@app.post("/api/bot/risk-questions/create") +async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = BotRiskQuestion( + question_key=body["question_key"], + question=body["question"], + weight=int(body.get("weight", 10)), + sort_order=int(body.get("sort_order", 0)), + is_active=body.get("is_active", True), + ) + db.add(q) + await db.commit() + return JSONResponse({"ok": True, "id": q.id}) + + +@app.post("/api/bot/risk-questions/edit") +async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + qid = body.get("id") + if not qid: + return JSONResponse({"ok": False, "error": "id required"}, status_code=400) + q = await db.get(BotRiskQuestion, int(qid)) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + if "question_key" in body: + q.question_key = body["question_key"] + if "question" in body: + q.question = body["question"] + if "weight" in body: + q.weight = int(body["weight"]) + if "sort_order" in body: + q.sort_order = int(body["sort_order"]) + if "is_active" in body: + q.is_active = body["is_active"] + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/risk-questions/toggle") +async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = await db.get(BotRiskQuestion, int(body["id"])) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + q.is_active = not q.is_active + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/risk-questions/delete") +async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = await db.get(BotRiskQuestion, int(body["id"])) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + await db.delete(q) + await db.commit() + return JSONResponse({"ok": True}) + + +# ===================== Tickets API ===================== + +@app.get("/api/bot/tickets") +async def api_bot_tickets(db: AsyncSession = Depends(get_db), + status: str = Query(None), + priority: str = Query(None)): + from app.models.models import BotTicket, BotUser + query = select(BotTicket, BotUser.first_name, BotUser.last_name).join( + BotUser, BotTicket.user_id == BotUser.id, isouter=True + ) + if status: + query = query.where(BotTicket.status == status) + if priority: + query = query.where(BotTicket.priority == priority) + query = query.order_by(BotTicket.created_at.desc()) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + { + "id": t.id, "ticket_number": t.ticket_number, + "title": t.title, "description": t.description[:200], + "priority": t.priority, "status": t.status, + "user_name": f"{fn or ''} {ln or ''}".strip() if fn else None, + "object_name": None, + "created_at": t.created_at.isoformat() if t.created_at else "", + } + for t, fn, ln in rows + ]) + + +@app.get("/api/bot/tickets/{ticket_id}") +async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotUser, BotTicketMessage + t = await db.get(BotTicket, ticket_id) + if not t: + return JSONResponse({"error": "Not found"}, status_code=404) + user = await db.get(BotUser, t.user_id) if t.user_id else None + result = await db.execute( + select(BotTicketMessage).where(BotTicketMessage.ticket_id == ticket_id) + .order_by(BotTicketMessage.created_at) + ) + messages = result.scalars().all() + return JSONResponse({ + "id": t.id, "ticket_number": t.ticket_number, + "title": t.title, "description": t.description, + "priority": t.priority, "status": t.status, + "user_name": f"{user.first_name} {user.last_name}".strip() if user else None, + "object_name": None, + "created_at": t.created_at.isoformat() if t.created_at else "", + "messages": [ + {"id": m.id, "direction": m.direction, "text": m.text, + "sender": m.sender, + "timestamp": m.created_at.isoformat() if m.created_at else ""} + for m in messages + ], + }) + + +@app.post("/api/bot/tickets/create") +async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus + from sqlalchemy import func + body = await request.json() + count = (await db.execute(select(func.count(BotTicket.id)))).scalar() or 0 + ticket_num = f"T-{count + 1:04d}" + t = BotTicket( + ticket_number=ticket_num, + title=body.get("title", ""), + description=body.get("description", ""), + priority=body.get("priority", "normal"), + status="open", + created_by=body.get("created_by", "portal"), + ) + db.add(t) + await db.flush() + db.add(BotTicketMessage(ticket_id=t.id, direction="in", text=body.get("description", ""), sender="client")) + db.add(BotTicketStatus(ticket_id=t.id, old_status="", new_status="open", changed_by="portal")) + await db.commit() + return JSONResponse({"ok": True, "id": t.id, "ticket_number": ticket_num}) + + +@app.post("/api/bot/tickets/{ticket_id}/message") +async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicketMessage + body = await request.json() + m = BotTicketMessage( + ticket_id=ticket_id, + direction=body.get("direction", "in"), + text=body.get("text", ""), + sender=body.get("sender", ""), + ) + db.add(m) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/tickets/{ticket_id}/status") +async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotTicketStatus + body = await request.json() + t = await db.get(BotTicket, ticket_id) + if not t: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + old = t.status + new_status = body.get("status", old) + db.add(BotTicketStatus(ticket_id=t.id, old_status=old, new_status=new_status, changed_by="portal")) + t.status = new_status + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/engineers") +async def api_bot_engineers(db: AsyncSession = Depends(get_db)): + from app.models.models import User + result = await db.execute( + select(User.id, User.full_name).where( + User.is_active == True, + User.role.in_(["engineer", "owner"]) + ).order_by(User.full_name) + ) + return JSONResponse([{"id": row[0], "full_name": row[1]} for row in result.fetchall()]) + + +@app.get("/api/bot/handoffs") +async def api_bot_handoffs(db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation, BotUser + query = ( + select(BotConversation, BotUser.first_name, BotUser.max_user_id) + .join(BotUser) + .where(BotConversation.status == "handoff") + .order_by(BotConversation.created_at.desc()) + ) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + { + "id": c.id, "user_name": fn, "max_user_id": muid, + "text": c.inquiry_text[:200] if c.inquiry_text else "", + "status": c.status, "priority": c.priority, + "created_at": c.created_at.isoformat() if c.created_at else "", + } + for c, fn, muid in rows + ]) + + +@app.post("/api/bot/handoffs/{handoff_id}/take") +async def api_bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation + conv = await db.get(BotConversation, handoff_id) + if not conv or conv.status != "handoff": + return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404) + conv.status = "open" + conv.priority = "normal" + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/handoffs/{handoff_id}/close") +async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation + from datetime import datetime + conv = await db.get(BotConversation, handoff_id) + if not conv or conv.status != "handoff": + return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404) + conv.status = "closed" + conv.closed_at = datetime.now() + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/notifications") +async def api_bot_notifications(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(50)): + from app.models.models import BotNotification + count_q = select(func.count(BotNotification.id)) + total = (await db.execute(count_q)).scalar() or 0 + query = ( + select(BotNotification) + .order_by(BotNotification.created_at.desc()) + .offset((page - 1) * limit).limit(limit) + ) + result = await db.execute(query) + notifications = result.scalars().all() + return JSONResponse({ + "total": total, + "notifications": [ + { + "id": n.id, "type": n.type, "recipient_type": n.recipient_type, + "title": n.title, "body": n.body[:200], + "is_read": n.is_read, + "created_at": n.created_at.isoformat() if n.created_at else "", + } + for n in notifications + ], + }) + + +@app.post("/api/bot/notifications/{notif_id}/read") +async def api_bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotNotification + n = await db.get(BotNotification, notif_id) + if not n: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + n.is_read = True + await db.commit() + return JSONResponse({"ok": True}) diff --git a/max_bot/app/max_api.py b/max_bot/app/max_api.py new file mode 100644 index 0000000..b5b0486 --- /dev/null +++ b/max_bot/app/max_api.py @@ -0,0 +1,159 @@ +import os +import re +import hmac +import hashlib +import logging +from datetime import datetime +from typing import Optional + +import aiohttp + +logger = logging.getLogger(__name__) + +MAX_API_URL = "https://platform-api.max.ru" + + +class MaxAPIClient: + def __init__(self, token: str, test_mode: bool = False): + self.token = token + self.test_mode = test_mode + self._session: Optional[aiohttp.ClientSession] = None + self.test_messages: list = [] + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() + + def _headers(self) -> dict: + return {"Authorization": self.token, "Content-Type": "application/json"} + + async def _request(self, method: str, path: str, **kwargs) -> dict: + if self.test_mode: + logger.info(f"[TEST_MODE] {method} {path} kwargs={kwargs}") + return {"ok": True, "test_mode": True} + session = await self._get_session() + url = f"{MAX_API_URL}{path}" + async with session.request(method, url, headers=self._headers(), **kwargs) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error(f"Max API error {resp.status}: {body}") + resp.raise_for_status() + return await resp.json() + + async def get_me(self) -> dict: + return await self._request("GET", "/me") + + async def send_message(self, user_id: int = None, chat_id: int = None, text: str = "", + attachments: list = None, format: str = None, + disable_link_preview: bool = False, notify: bool = True) -> dict: + body = {"text": text} + if attachments: + body["attachments"] = attachments + if format: + body["format"] = format + if not disable_link_preview: + body["disable_link_preview"] = False + if not notify: + body["notify"] = False + + params = {} + if user_id: + params["user_id"] = user_id + if chat_id: + params["chat_id"] = chat_id + + if self.test_mode: + msg = {"role": "bot", "text": text, "format": format} + if attachments: + msg["attachments"] = attachments + self.test_messages.append(msg) + return await self._request("POST", "/messages", json=body, params=params) + + async def send_message_with_keyboard(self, user_id: int, text: str, buttons: list, + format: str = None) -> dict: + attachments = [{ + "type": "inline_keyboard", + "payload": {"buttons": buttons} + }] + return await self.send_message(user_id=user_id, text=text, attachments=attachments, format=format) + + async def edit_message(self, message_id: int, text: str, attachments: list = None) -> dict: + body = {"text": text} + if attachments: + body["attachments"] = attachments + return await self._request("PUT", f"/messages/{message_id}", json=body) + + async def get_message(self, message_id: int) -> dict: + return await self._request("GET", f"/messages/{message_id}") + + async def get_messages(self, user_id: int = None, chat_id: int = None, limit: int = 10) -> dict: + params = {"limit": limit} + if user_id: + params["user_id"] = user_id + if chat_id: + params["chat_id"] = chat_id + return await self._request("GET", "/messages", params=params) + + async def answer_callback(self, callback_id: str, text: str = "", show_alert: bool = False) -> dict: + body = {"callback_id": callback_id} + if text: + body["text"] = text + if show_alert: + body["show_alert"] = True + return await self._request("POST", "/answers", json=body) + + async def subscribe_webhook(self, url: str, update_types: list = None, secret: str = None) -> dict: + body = {"url": url} + if update_types: + body["update_types"] = update_types + if secret: + body["secret"] = secret + return await self._request("POST", "/subscriptions", json=body) + + async def get_subscriptions(self) -> dict: + return await self._request("GET", "/subscriptions") + + async def unsubscribe_webhook(self) -> dict: + return await self._request("DELETE", "/subscriptions") + + async def upload_file(self, file_path: str) -> dict: + session = await self._get_session() + url = f"{MAX_API_URL}/uploads" + with open(file_path, "rb") as f: + data = aiohttp.FormData() + data.add_field("file", f, filename=os.path.basename(file_path)) + async with session.post(url, headers={"Authorization": self.token}, data=data) as resp: + resp.raise_for_status() + return await resp.json() + + def verify_webhook_secret(self, header_value: str, expected_secret: str) -> bool: + if not expected_secret: + return True + return hmac.compare_digest(header_value, expected_secret) + + async def send_file(self, user_id: int, file_path: str, caption: str = "") -> dict: + upload = await self.upload_file(file_path) + file_id = upload.get("file_id", "") + body = {"text": caption, "attachments": [{"type": "file", "payload": {"file_id": file_id}}]} + return await self._request("POST", "/messages", json=body, params={"user_id": user_id}) + + async def get_file_url(self, file_id: str) -> str: + resp = await self._request("GET", f"/uploads/{file_id}") + return resp.get("url", "") + + async def send_contact_request(self, user_id: int, text: str = "Пожалуйста, отправьте ваш контакт") -> dict: + attachments = [{ + "type": "contact_request", + "payload": {"text": text} + }] + return await self.send_message(user_id=user_id, text=text, attachments=attachments) + + def verify_contact_hash(self, vcf_info: str, access_token: str, expected_hash: str) -> bool: + vcf_normalized = vcf_info.replace("\\r\\n", "\r\n") + computed = hmac.new(access_token.encode(), vcf_normalized.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(computed, expected_hash) diff --git a/max_bot/app/max_notifications.py b/max_bot/app/max_notifications.py new file mode 100644 index 0000000..894d1c2 --- /dev/null +++ b/max_bot/app/max_notifications.py @@ -0,0 +1,148 @@ +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import ( + BotNotification, BotNotificationSubscription, BotUser, +) +from app.settings_cache import SettingsCache + +try: + from app.models.models import User +except ImportError: + User = None + + +async def _get_max_user_id_by_role(db: AsyncSession, role: str) -> int | None: + if User is None: + return None + result = await db.execute( + select(User.max_user_id).where( + User.role == role, User.is_active == True, + User.max_user_id.isnot(None) + ).limit(1) + ) + return result.scalar_one_or_none() + + +async def _get_user_max_ids_by_role(db: AsyncSession, role: str) -> list[int]: + if User is None: + return [] + result = await db.execute( + select(User.max_user_id).where( + User.role == role, User.is_active == True, + User.max_user_id.isnot(None) + ) + ) + return [row[0] for row in result.fetchall() if row[0]] + + +async def _log_notification(db: AsyncSession, ntype: str, recipient_type: str, + recipient_max_user_id: int | None, title: str, body: str, + related_type: str = "", related_id: int | None = None): + n = BotNotification( + type=ntype, recipient_type=recipient_type, + recipient_max_user_id=recipient_max_user_id, + title=title, body=body, + related_type=related_type, related_id=related_id + ) + db.add(n) + await db.flush() + return n + + +async def notify_operator(db: AsyncSession, max_api, text: str, title: str = "", + related_type: str = "", related_id: int | None = None): + max_ids = await _get_user_max_ids_by_role(db, "owner") + sent_count = 0 + for mid in max_ids: + try: + await max_api.send_message(user_id=mid, text=text, format="markdown") + sent_count += 1 + except Exception: + pass + await _log_notification( + db, "operator_alert", "operator", max_ids[0] if max_ids else None, + title or "Уведомление оператору", text, + related_type, related_id + ) + return sent_count + + +async def notify_level2(db: AsyncSession, max_api, text: str, title: str = "", + related_type: str = "", related_id: int | None = None): + max_ids = await _get_user_max_ids_by_role(db, "engineer") + sent_count = 0 + for mid in max_ids: + try: + await max_api.send_message(user_id=mid, text=text, format="markdown") + sent_count += 1 + except Exception: + pass + await _log_notification( + db, "level2_alert", "level2", max_ids[0] if max_ids else None, + title or "Уведомление инженеру", text, + related_type, related_id + ) + return sent_count + + +async def notify_client_status_change(db: AsyncSession, max_api, + bot_user: BotUser, conv_id: int, + new_status: str): + text = f"Статус вашего обращения №{conv_id} изменён на «{new_status}»." + try: + await max_api.send_message(user_id=bot_user.max_user_id, text=text, format="markdown") + except Exception: + pass + await _log_notification( + db, "status_change", "client", bot_user.max_user_id, + "Статус обращения", text, "conversation", conv_id + ) + + +async def notify_ticket_created(db: AsyncSession, ticket, max_api): + ticket_text = ( + f"🆕 **Новый тикет {ticket.ticket_number}**\n" + f"Приоритет: {ticket.priority}\n" + f"Описание: {ticket.description[:200]}\n" + f"Обращение №{ticket.id}" + ) + await notify_level2( + db, max_api, ticket_text, + title=f"Тикет {ticket.ticket_number}", + related_type="ticket", related_id=ticket.id + ) + + +async def notify_handoff_escalation(db: AsyncSession, max_api, + bot_user: BotUser, conv_id: int, + text: str = ""): + msg = ( + f"🔴 **Эскалация оператору**\n" + f"Пользователь: {bot_user.first_name or '—'} (ID {bot_user.max_user_id})\n" + f"Обращение №{conv_id}\n" + f"Текст: {text[:200] if text else '—'}" + ) + await notify_operator( + db, max_api, msg, + title="Эскалация", + related_type="conversation", related_id=conv_id + ) + + +async def get_subscribed_users(db: AsyncSession, notify_type: str = "broadcast") -> list[BotUser]: + col_map = { + "broadcast": BotNotificationSubscription.notify_on_broadcast, + "status_change": BotNotificationSubscription.notify_on_status_change, + "reply": BotNotificationSubscription.notify_on_reply, + } + col = col_map.get(notify_type) + if col is None: + return [] + result = await db.execute( + select(BotUser).join( + BotNotificationSubscription, + BotNotificationSubscription.user_id == BotUser.id + ).where(col == True) + ) + return list(result.scalars().all()) diff --git a/max_bot/app/models/__init__.py b/max_bot/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/models/__pycache__/__init__.cpython-314.pyc b/max_bot/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e632cb34e816d21861fba8cf6db17d5539709111 GIT binary patch literal 157 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 DBH<$x literal 0 HcmV?d00001 diff --git a/max_bot/app/models/__pycache__/models.cpython-38.pyc b/max_bot/app/models/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..176ba38a1be7f82d97be4cf4d9e4d0284bb43fbe GIT binary patch literal 6467 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 literal 0 HcmV?d00001 diff --git a/max_bot/app/models/models.py b/max_bot/app/models/models.py new file mode 100644 index 0000000..af39e90 --- /dev/null +++ b/max_bot/app/models/models.py @@ -0,0 +1,350 @@ +from datetime import datetime +from sqlalchemy import ( + Column, Integer, String, Boolean, Text, DateTime, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, text as sa_text +) +from sqlalchemy.orm import relationship, declarative_base + +Base = declarative_base() + + +class BotUser(Base): + __tablename__ = "bot_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + max_user_id = Column(BigInteger, unique=True, nullable=False) + first_name = Column(String(255), default="") + last_name = Column(String(255), default="") + username = Column(String(255), default="") + phone = Column(String(50), default="") + email = Column(String(255), default="") + consent_given = Column(Boolean, nullable=False, default=False) + consent_timestamp = Column(DateTime, nullable=True) + consent_method = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + last_active_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + conversations = relationship("BotConversation", back_populates="user") + consent_logs = relationship("BotConsentLog", back_populates="user") + notification_subscriptions = relationship("BotNotificationSubscription", back_populates="user", uselist=False) + + +class BotConversation(Base): + __tablename__ = "bot_conversations" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + inquiry_type = Column(Integer, ForeignKey("bot_categories.id", ondelete="SET NULL"), nullable=True) + inquiry_text = Column(Text, default="") + priority = Column(String(20), default="normal") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + closed_at = Column(DateTime, nullable=True) + assigned_to = Column(String(255), default="") + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + + __table_args__ = ( + CheckConstraint("status IN ('open','closed','handoff','1c_sent','awaiting_rating')", name="ck_bot_conv_status"), + CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_conv_priority"), + ) + + user = relationship("BotUser", back_populates="conversations") + category = relationship("BotCategory", back_populates="conversations") + messages = relationship("BotMessage", back_populates="conversation", order_by="BotMessage.timestamp") + sync_log = relationship("Bot1CSyncLog", back_populates="conversation") + + +class BotMessage(Base): + __tablename__ = "bot_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False) + direction = Column(String(3), nullable=False) + text = Column(Text, default="") + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + max_message_id = Column(BigInteger, nullable=True) + timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("direction IN ('in','out')", name="ck_bot_msg_direction"), + ) + + conversation = relationship("BotConversation", back_populates="messages") + + +class BotConsentLog(Base): + __tablename__ = "bot_consent_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + action = Column(String(20), nullable=False) + timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + method = Column(String(50), default="") + ip_address = Column(String(45), default="") + + __table_args__ = ( + CheckConstraint("action IN ('given','revoked')", name="ck_bot_consent_action"), + ) + + user = relationship("BotUser", back_populates="consent_logs") + + +class Bot1CSyncLog(Base): + __tablename__ = "bot_1c_sync_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="pending") + request_payload = Column(Text, default="") + response_payload = Column(Text, default="") + sent_at = Column(DateTime, nullable=True) + confirmed_at = Column(DateTime, nullable=True) + + __table_args__ = ( + CheckConstraint("status IN ('pending','sent','confirmed','error')", name="ck_bot_1c_status"), + ) + + conversation = relationship("BotConversation", back_populates="sync_log") + + +class BotKnowledgeBase(Base): + __tablename__ = "bot_knowledge_base" + + id = Column(Integer, primary_key=True, autoincrement=True) + question = Column(String(500), nullable=False) + answer = Column(Text, nullable=False) + category = Column(String(100), default="") + keywords = Column(JSON, default=list) + active = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, default=0) + + +class BotCategory(Base): + __tablename__ = "bot_categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + description = Column(String(500), default="") + active = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, default=0) + icon_emoji = Column(String(10), default="📋") + keywords = Column(JSON, default=list) + + conversations = relationship("BotConversation", back_populates="category") + + +class BotSetting(Base): + __tablename__ = "bot_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(100), unique=True, nullable=False) + value = Column(Text, default="") + description = Column(String(500), default="") + + +class BotFeature(Base): + __tablename__ = "bot_features" + + id = Column(Integer, primary_key=True, autoincrement=True) + feature_key = Column(String(100), unique=True, nullable=False) + name = Column(String(100), nullable=False) + description = Column(String(500), default="") + enabled = Column(Boolean, nullable=False, default=False) + sort_order = Column(Integer, default=0) + + +class BotResponseTemplate(Base): + __tablename__ = "bot_response_templates" + + id = Column(Integer, primary_key=True, autoincrement=True) + template_key = Column(String(100), unique=True, nullable=False) + template_text = Column(Text, nullable=False) + description = Column(String(500), default="") + variables = Column(JSON, default=list) + + +class FileSyncQueue(Base): + __tablename__ = "file_sync_queue" + + id = Column(Integer, primary_key=True, autoincrement=True) + local_path = Column(String(500), nullable=False) + remote_path = Column(String(500), default="") + entity_type = Column(String(50), nullable=False) + entity_id = Column(Integer, nullable=False) + status = Column(String(20), nullable=False, default="pending") + error = Column(Text, default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("status IN ('pending','uploading','synced','error')", name="ck_file_sync_status"), + ) + + +class BotBroadcast(Base): + __tablename__ = "bot_broadcasts" + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(Text, nullable=False) + sent_at = Column(DateTime, nullable=True) + sent_by = Column(Integer, nullable=True) + recipient_count = Column(Integer, default=0) + recipient_ids = Column(JSON, default=list) + status = Column(String(20), default="draft") + + __table_args__ = ( + CheckConstraint("status IN ('draft','sent','failed')", name="ck_bot_broadcast_status"), + ) + + +class BotNotification(Base): + __tablename__ = "bot_notifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + type = Column(String(50), nullable=False) + recipient_type = Column(String(20), nullable=False) + recipient_max_user_id = Column(BigInteger, nullable=True) + title = Column(String(255), default="") + body = Column(Text, default="") + related_type = Column(String(50), default="") + related_id = Column(Integer, nullable=True) + is_read = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("recipient_type IN ('operator','level2','client')", name="ck_bot_notif_recipient"), + ) + + +class BotNotificationSubscription(Base): + __tablename__ = "bot_notification_subscriptions" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + notify_on_status_change = Column(Boolean, nullable=False, default=False) + notify_on_reply = Column(Boolean, nullable=False, default=False) + notify_on_broadcast = Column(Boolean, nullable=False, default=False) + + user = relationship("BotUser", back_populates="notification_subscriptions") + + +class Customer(Base): + __tablename__ = "customers" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + contact_phone = Column(String(50), default="") + + +class Object(Base): + __tablename__ = "objects" + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"), nullable=True) + name = Column(String(255), nullable=False) + status = Column(String(20), nullable=False, default="active") + risk_score = Column(Integer, default=0) + + +class SLAContract(Base): + __tablename__ = "sla_contracts" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="negotiation") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + service_level = Column(String(20), default="start") + sla_price_monthly = Column(Integer, default=0) + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + + +class Incident(Base): + __tablename__ = "incidents" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + created_at = Column(DateTime, nullable=False, server_default="NOW()") + title = Column(String(500), default="") + description = Column(Text, default="") + severity = Column(String(4), nullable=False, default="P3") + reported_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + login = Column(String(50), default="") + full_name = Column(String(255), default="") + role = Column(String(20), nullable=False, default="technician") + is_active = Column(Boolean, nullable=False, default=True) + max_user_id = Column(BigInteger, nullable=True) + + +class BotRiskQuestion(Base): + __tablename__ = "bot_risk_questions" + + id = Column(Integer, primary_key=True, autoincrement=True) + question_key = Column(String(100), unique=True, nullable=False) + question = Column(String(500), nullable=False) + weight = Column(Integer, nullable=False, default=10) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, nullable=False, default=True) + + +class BotTicket(Base): + __tablename__ = "bot_tickets" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_number = Column(String(20), unique=True, nullable=False) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="SET NULL"), nullable=True) + object_id = Column(Integer, nullable=True) + customer_id = Column(Integer, nullable=True) + title = Column(String(255), nullable=False) + description = Column(Text, default="") + priority = Column(String(20), default="normal") + status = Column(String(20), nullable=False, default="open") + created_by = Column(String(20), default="bot") + sla_contract_id = Column(Integer, nullable=True) + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + updated_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_ticket_priority"), + CheckConstraint("status IN ('open','in_progress','resolved','closed')", name="ck_bot_ticket_status"), + ) + + +class BotTicketMessage(Base): + __tablename__ = "bot_ticket_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False) + direction = Column(String(10), nullable=False) + text = Column(Text, default="") + sender = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + +class BotTicketStatus(Base): + __tablename__ = "bot_ticket_statuses" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False) + old_status = Column(String(20), default="") + new_status = Column(String(20), nullable=False) + changed_by = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) diff --git a/max_bot/app/settings_cache.py b/max_bot/app/settings_cache.py new file mode 100644 index 0000000..f4587cb --- /dev/null +++ b/max_bot/app/settings_cache.py @@ -0,0 +1,129 @@ +import time +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import BotSetting, BotFeature, BotResponseTemplate, BotCategory + + +class SettingsCache: + _cache: dict = {} + _features: dict = {} + _templates: dict = {} + _categories: list = [] + _last_update: float = 0 + TTL = 300 # 5 minutes + + @classmethod + async def refresh(cls, db: AsyncSession): + now = time.time() + if now - cls._last_update < cls.TTL and cls._cache: + return + + result = await db.execute(select(BotSetting)) + cls._cache = {row.key: row.value for row in result.scalars().all()} + + result = await db.execute(select(BotFeature).where(BotFeature.enabled == True)) + cls._features = {row.feature_key: row for row in result.scalars().all()} + + result = await db.execute(select(BotFeature)) + all_features = {row.feature_key: row for row in result.scalars().all()} + + result = await db.execute(select(BotResponseTemplate)) + cls._templates = {row.template_key: row.template_text for row in result.scalars().all()} + + result = await db.execute( + select(BotCategory).where(BotCategory.active == True).order_by(BotCategory.sort_order) + ) + cls._categories = result.scalars().all() + + cls._last_update = now + + @classmethod + def get(cls, key: str, default: str = "") -> str: + return cls._cache.get(key, default) + + @classmethod + def get_int(cls, key: str, default: int = 0) -> int: + val = cls._cache.get(key, str(default)) + try: + return int(val) + except (ValueError, TypeError): + return default + + @classmethod + def get_list(cls, key: str, default: list = None) -> list: + val = cls._cache.get(key, "") + if not val: + return default or [] + return [x.strip() for x in val.split(",")] + + @classmethod + def get_all(cls) -> dict: + return dict(cls._cache) + + @classmethod + def is_feature_enabled(cls, feature_key: str) -> bool: + return feature_key in cls._features + + @classmethod + def get_enabled_features(cls) -> dict: + return dict(cls._features) + + @classmethod + def get_template(cls, key: str, default: str = "") -> str: + return cls._templates.get(key, default) + + @classmethod + def get_all_templates(cls) -> dict: + return dict(cls._templates) + + @classmethod + def get_categories(cls) -> list: + return list(cls._categories) + + @classmethod + def get_bot_token(cls) -> str: + return cls._cache.get("bot_token", "") + + @classmethod + def get_assistant_name(cls) -> str: + return cls._cache.get("assistant_name", "София") + + @classmethod + def is_work_hours(cls) -> bool: + import datetime + import pytz + tz_name = cls._cache.get("timezone", "Europe/Moscow") + try: + tz = pytz.timezone(tz_name) + except Exception: + tz = pytz.timezone("Europe/Moscow") + now = datetime.datetime.now(tz) + work_days = cls.get_list("work_days", ["1", "2", "3", "4", "5"]) + weekday = str(now.isoweekday()) + if weekday not in work_days: + return False + start = cls.get_int("work_hours_start", 9) + end = cls.get_int("work_hours_end", 18) + return start <= now.hour < end + + @classmethod + def get_work_hours_str(cls) -> str: + start = cls.get_int("work_hours_start", 9) + end = cls.get_int("work_hours_end", 18) + return f"{start}:00-{end}:00" + + @classmethod + def get_phones(cls) -> list: + phones = [] + p1 = cls._cache.get("phone_1", "") + p2 = cls._cache.get("phone_2", "") + if p1: + phones.append(p1) + if p2: + phones.append(p2) + return phones + + @classmethod + def get_email(cls) -> str: + return cls._cache.get("support_email", "") diff --git a/max_bot/app/text_renderer.py b/max_bot/app/text_renderer.py new file mode 100644 index 0000000..de2afdc --- /dev/null +++ b/max_bot/app/text_renderer.py @@ -0,0 +1,34 @@ +import re +from typing import Optional + + +def render_template(template: str, **kwargs) -> str: + result = template + for key, value in kwargs.items(): + result = result.replace("{" + key + "}", str(value)) + remaining = re.findall(r'\{(\w+)\}', result) + for var in remaining: + result = result.replace("{" + var + "}", "") + return result + + +def parse_link_text(text: str) -> str: + text = re.sub(r'\[([^\]]+)\]\(tel:[^\)]+\)', r'\1', text) + text = re.sub(r'\[([^\]]+)\]\(mailto:[^\)]+\)', r'\1', text) + text = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', text) + return text + + +def make_clickable_links(text: str, phone_1: str = "", phone_2: str = "", + email: str = "", email_subject: str = "") -> str: + phone_1_raw = re.sub(r'[^\d+]', '', phone_1) + phone_2_raw = re.sub(r'[^\d+]', '', phone_2) + email_encoded = email.replace(' ', '%20') + subject_encoded = email_subject.replace(' ', '%20') + + text = text.replace("{phone_1_raw}", phone_1_raw) + text = text.replace("{phone_2_raw}", phone_2_raw) + text = text.replace("{email_encoded}", email_encoded) + text = text.replace("{email_subject_encoded}", subject_encoded) + + return text diff --git a/max_bot/app/utils/__init__.py b/max_bot/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc b/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..943351ab51e1fbbcd9b8c6abf12fadd35c92d9f0 GIT binary patch literal 156 zcmdPq_I|p@<2{{|u76W$9uS6PcKo zl3L-CS)3hHoLQ0@pO~7SS)8Aj8k3t?5ucP_5|da^5K~%`nNu7SAD@|*SrQ+wS5SG2 f!zMRBr8Fniu80+A63DJ%5aSawBO_xGGmr%U^spr@ literal 0 HcmV?d00001 diff --git a/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc b/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1f4b950d1a0c8272d1e3a81ec2a590e713b624f GIT binary patch literal 2233 zcmb_dO>7fK6rS1je;mhwg#6IJ@rdx;QPYkU&HI}- zZ@zi^ZlKXeU|jz-cy`1=$nUtY2UllS2Z1S(lO#?Lk^@wyA>_G(+yP$aLnKP#o}*NE z#Q8Q7ceIgQPE^raqShX;RU% ziY%PZ>Su&ZUQbc*z=;ELBRtI29Y9JXK~8cXd_hPWWZmRAJwxL(2^wp}xplsu3zJ#? z1)jxp=u%NPU8$IdxDP2*c`6<&c2%;=2x(Kd<8i25Vm|7>05_wI$ zH2f0DHEjhyH#D|d1ubD`Mg|9-c?RZc6-XZmf%E#gj@h>ZI(uv(ak8sku{=%Ccj}6C z2&>dZl1|$)v1T_0w|W+;SfnNXu+vBKT!M>pd%)1DZ35~Cp5C`T!^BMvcl45^%htI_ z8glN2Onb=T&Meu>_mC{z&1Y$ZHv?0wsCcu&IcaJfM4r3?<;{L3nQmDs=&G1AJ+hP$ zQ512ic;6I+j_1vuJ9!I;VTdE_k2IG+|Xw@`d&7WO|dtxTqM}>icoL! z6Mt}phwtCo`>@Y+XsCz&7#Q-CIXdKX%=>|>G=l8Dj{0|!7r)$*mM!GJW7rSHj$^?+&WE9iLBn$X>s)xIoiJCo0~w&!W1JwP@GL%UKEa+# zk(8VvmIup#gb4#a9iJrtbuPNYbRc3)A2uN&r<|A6Iyg%fIK2ZINtTLvb2n6zQvW-n% zl&8!lVSHq4XyRaEMELk9y4(ckF>X_N1xfNka>R%Xp-ffwp&Rjflpg|H~b zJS4>I?tx>O9kzn$)V?XO)Gt7JU`JyS?F;z4VaFL`vCxIV7r>$ci+7b>!{E@f0`+ls73*V{a6 z+v+w*fi+r&Aaye?v2R$Q_Y6!b=|WB#R3jk6R-ttRfp$shGqU{|+4qOTxsfY1{TYN` L str: + gpt_key = SettingsCache.get("yandex_gpt_key") + if gpt_key: + try: + result = await analyze_emotion_with_gpt(text) + if result in ("positive", "neutral", "negative"): + return result + except Exception: + pass + return _keyword_fallback(text) + + +def _keyword_fallback(text: str) -> str: + text_lower = text.lower() + if any(word in text_lower for word in _NEGATIVE_WORDS): + return "negative" + return "neutral" diff --git a/max_bot/docker-compose.yml b/max_bot/docker-compose.yml new file mode 100644 index 0000000..f392e33 --- /dev/null +++ b/max_bot/docker-compose.yml @@ -0,0 +1,15 @@ +services: + max-bot: + build: + context: . + network: host + container_name: aegisone-max-bot + network_mode: host + environment: + DATABASE_URL: postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone + APP_ENV: production + LOG_LEVEL: info + WEBHOOK_SECRET: ${WEBHOOK_SECRET:-max-bot-secret-key} + volumes: + - ./uploads:/app/uploads + restart: unless-stopped diff --git a/max_bot/max_bot_deploy.sh b/max_bot/max_bot_deploy.sh new file mode 100644 index 0000000..82e8fa4 --- /dev/null +++ b/max_bot/max_bot_deploy.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# ========================================== +# MAX Bot — Deploy Script +# ========================================== +# Usage: bash max_bot_deploy.sh +# Run on VPS as angel user (or from local with SSH) +set -e + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $1"; } +ok() { echo -e " ${GREEN}✅ $1${NC}"; } +info() { echo -e " ${YELLOW}ℹ️ $1${NC}"; } +fail() { echo -e " ${RED}❌ $1${NC}"; exit 1; } + +cd "$(dirname "$0")" + +echo -e "${CYAN}" +echo "==========================================" +echo " MAX Bot — Deploy" +echo "==========================================" +echo -e "${NC}" + +BOT="/opt/projects/aegisone-py/max_bot" +PY="/opt/projects/aegisone-py" +NGX="/opt/projects/nginx-proxy" + +# ========================================== +# 1. Deploy max_bot files +# ========================================== +log "Deploying max_bot files..." +mkdir -p "$BOT" +rsync -a --delete ./ "$BOT/" 2>/dev/null || cp -r ./* "$BOT/" +ok "Files deployed to $BOT" + +# ========================================== +# 2. Deploy nginx config +# ========================================== +log "Deploying nginx config for max.aegisone.ru..." +cp max_bot/nginx/max-aegisone.conf "$NGX/conf.d/max-aegisone.conf" +docker compose -f "$NGX/docker-compose.yml" exec nginx nginx -s reload 2>/dev/null || true +ok "Nginx reloaded" + +# ========================================== +# 3. Run SQL migrations +# ========================================== +PG_CONTAINER=$(docker ps --filter "name=aegisone-postgres" --format "{{.ID}}" | head -1) +if [ -n "$PG_CONTAINER" ]; then + log "Running SQL migration v1.5.0..." + cat "$PY/sql/v1.5.0_migration.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.0 applied" + + log "Running SQL migration v1.5.2..." + cat "$BOT/sql/migrations/v1.5.2_role_menu_permissions.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.2 applied" + + log "Running SQL migration v1.5.5..." + cat "$BOT/sql/migrations/v1.5.5_tables_and_seed.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.5 applied" +else + info "PostgreSQL container not found — run migration manually" +fi + +# ========================================== +# 4. Run SQL migration v1.6.0 via docker exec pipe +# ========================================== +PG_CONTAINER=$(docker ps --filter "name=aegisone-postgres" --format "{{.ID}}" | head -1) +if [ -n "$PG_CONTAINER" ]; then + log "Running SQL migration v1.6.0..." + cat "$BOT/sql/migrations/v1.6.0_fixes.sql" | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^UPDATE\|^DELETE\|^ALTER TABLE\|^CREATE INDEX\|^DROP VIEW" || true + ok "SQL migration v1.6.0 applied" +else + info "PostgreSQL container not found — run migration manually" +fi + +# ========================================== +# 5. Build & start max_bot +# ========================================== +log "Building and starting max_bot..." +cd "$BOT" && docker compose up -d --build +ok "max_bot started on port 8002" + +# ========================================== +# 6. Health check +# ========================================== +log "Checking health..." +sleep 3 +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8002/health 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + ok "max_bot is healthy (HTTP $HTTP_CODE)" +else + info "Health check returned HTTP $HTTP_CODE — check logs: docker logs aegisone-max-bot" +fi + +# ========================================== +# Done +# ========================================== +echo "" +echo -e "${GREEN}==========================================" +echo " MAX Bot deploy complete!" +echo "==========================================${NC}" +echo "" +echo " Bot webhook: https://max.aegisone.ru/webhook" +echo " Bot API: https://max.aegisone.ru/api/" +echo " Health check: http://localhost:8002/health" +echo " Logs: docker logs -f aegisone-max-bot" +echo "" diff --git a/max_bot/nginx/max-aegisone.conf b/max_bot/nginx/max-aegisone.conf new file mode 100644 index 0000000..b9b019c --- /dev/null +++ b/max_bot/nginx/max-aegisone.conf @@ -0,0 +1,52 @@ +server { + listen 443 ssl; + http2 on; + server_name max.aegisone.ru; + + ssl_certificate /etc/nginx/certs/live/max.aegisone.ru/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/max.aegisone.ru/privkey.pem; + + client_max_body_size 50M; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location /webhook { + proxy_pass http://127.0.0.1:8002/webhook; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://127.0.0.1:8002/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://127.0.0.1:8002/health; + proxy_set_header Host $host; + } + + location / { + return 404; + } +} + +server { + listen 80; + server_name max.aegisone.ru; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/max_bot/requirements.txt b/max_bot/requirements.txt new file mode 100644 index 0000000..55c6b31 --- /dev/null +++ b/max_bot/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +aiohttp==3.11.11 +pydantic-settings==2.7.0 +python-multipart==0.0.18 +pytz==2024.2 +pytest==8.3.4 +pytest-asyncio==0.25.3 +httpx==0.28.1 +aiosqlite==0.20.0 diff --git a/max_bot/sql/bot_schema.sql b/max_bot/sql/bot_schema.sql new file mode 100644 index 0000000..7faa988 --- /dev/null +++ b/max_bot/sql/bot_schema.sql @@ -0,0 +1,205 @@ +-- Bot tables for AegisOne Engineering Max Bot "София" +-- Run against the shared aegisone database + +CREATE TABLE IF NOT EXISTS bot_users ( + id SERIAL PRIMARY KEY, + max_user_id BIGINT UNIQUE NOT NULL, + first_name VARCHAR(255) DEFAULT '', + last_name VARCHAR(255) DEFAULT '', + username VARCHAR(255) DEFAULT '', + phone VARCHAR(50) DEFAULT '', + email VARCHAR(255) DEFAULT '', + consent_given BOOLEAN NOT NULL DEFAULT FALSE, + consent_timestamp TIMESTAMP NULL, + consent_method VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_active_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + icon_emoji VARCHAR(10) DEFAULT '📋', + keywords JSONB DEFAULT '[]'::jsonb +); + +CREATE TABLE IF NOT EXISTS bot_conversations ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed','handoff','1c_sent','awaiting_rating')), + inquiry_type INTEGER REFERENCES bot_categories(id) ON DELETE SET NULL, + inquiry_text TEXT DEFAULT '', + priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + closed_at TIMESTAMP NULL, + assigned_to VARCHAR(255) DEFAULT '', + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES bot_conversations(id) ON DELETE CASCADE, + direction VARCHAR(3) NOT NULL CHECK (direction IN ('in','out')), + text TEXT DEFAULT '', + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '', + max_message_id BIGINT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_consent_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + action VARCHAR(20) NOT NULL CHECK (action IN ('given','revoked')), + timestamp TIMESTAMP NOT NULL DEFAULT NOW(), + method VARCHAR(50) DEFAULT '', + ip_address VARCHAR(45) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_1c_sync_log ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES bot_conversations(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','confirmed','error')), + request_payload TEXT DEFAULT '', + response_payload TEXT DEFAULT '', + sent_at TIMESTAMP NULL, + confirmed_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS bot_knowledge_base ( + id SERIAL PRIMARY KEY, + question VARCHAR(500) NOT NULL, + answer TEXT NOT NULL, + category VARCHAR(100) DEFAULT '', + keywords JSONB DEFAULT '[]'::jsonb, + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_settings ( + id SERIAL PRIMARY KEY, + key VARCHAR(100) UNIQUE NOT NULL, + value TEXT DEFAULT '', + description VARCHAR(500) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_features ( + id SERIAL PRIMARY KEY, + feature_key VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_response_templates ( + id SERIAL PRIMARY KEY, + template_key VARCHAR(100) UNIQUE NOT NULL, + template_text TEXT NOT NULL, + description VARCHAR(500) DEFAULT '', + variables JSONB DEFAULT '[]'::jsonb +); + +CREATE TABLE IF NOT EXISTS bot_broadcasts ( + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + sent_at TIMESTAMP NULL, + sent_by INTEGER NULL, + recipient_count INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft','sent','failed')) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_bot_conv_user ON bot_conversations(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_conv_status ON bot_conversations(status); +CREATE INDEX IF NOT EXISTS idx_bot_msg_conv ON bot_messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_bot_consent_user ON bot_consent_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_1c_conv ON bot_1c_sync_log(conversation_id); +CREATE INDEX IF NOT EXISTS idx_bot_1c_status ON bot_1c_sync_log(status); +CREATE INDEX IF NOT EXISTS idx_bot_kb_active ON bot_knowledge_base(active); +CREATE INDEX IF NOT EXISTS idx_bot_cat_active ON bot_categories(active); + +-- Initial settings +INSERT INTO bot_settings (key, value, description) VALUES +('bot_token', 'id2311381465_bot', 'Токен бота Max'), +('assistant_name', 'София', 'Имя виртуального помощника'), +('phone_1', '8 (861) 203-33-30', 'Телефон 1'), +('phone_2', '8 (995) 203-33-30', 'Телефон 2'), +('support_email', 'zakaz@aegisone.ru', 'Email поддержки'), +('email_subject', 'Обращение через Виртуального помощника AegisOne Engineering', 'Тема письма при отказе от согласия'), +('work_hours_start', '9', 'Начало рабочего времени (час)'), +('work_hours_end', '18', 'Конец рабочего времени (час)'), +('work_days', '1,2,3,4,5', 'Рабочие дни (1=Пн, 7=Вс)'), +('timezone', 'Europe/Moscow', 'Часовой пояс'), +('1c_webhook_url', '', 'URL webhook 1С:УНФ'), +('consent_policy_url', 'https://aegisone.ru/politica.php', 'Ссылка на политику ПД'), +('yandex_gpt_key', '', 'Ключ YandexGPT (пусто = ключевые слова)'), +('yandex_folder_id', '', 'Folder ID YandexGPT') +ON CONFLICT (key) DO NOTHING; + +-- Initial features +INSERT INTO bot_features (feature_key, name, description, enabled, sort_order) VALUES +('risk_score', 'Risk Score калькулятор', 'Расчёт оценки рисков объекта', false, 1), +('sla_status', 'Проверка SLA статуса', 'Проверка статуса SLA контракта по номеру объекта', false, 2), +('photo_report', 'Фото-отчёт', 'Отправка фото проблемы с созданием инцидента', false, 3), +('appointment', 'Запись на визит', 'Запись на визит инженера', false, 4), +('quality_rate', 'Оценка качества', 'Оценка качества после закрытия обращения', false, 5), +('commercial', 'Коммерческое предложение', 'Генерация КП по запросу', false, 6), +('reminders', 'Напоминания о ТО', 'Автоматические напоминания о плановом ТО', false, 7), +('my_objects', 'Мои объекты', 'Просмотр информации по объектам клиента', false, 8), +('notifications', 'Уведомления о статусе', 'Автоматические уведомления при изменении статуса', false, 9), +('broadcasts', 'Рассылки', 'Массовая рассылка сообщений клиентам', false, 10), +('sla_ticket', 'SLA заявки', 'Подача заявки о неисправности клиентом с активным SLA', false, 11) +ON CONFLICT (feature_key) DO NOTHING; + +-- Initial categories +INSERT INTO bot_categories (name, description, icon_emoji, keywords, sort_order) VALUES +('Видеонаблюдение', 'Камеры, видеорегистраторы, хранилища', '📹', '["камер","видео","наблюден","регистратор","хранен","запись"]', 1), +('СКУД', 'Контроль доступа, турникеты, замки', '🔐', '["скуд","доступ","турникет","замок","пропуск","карт"]', 2), +('Пожарная сигнализация', 'Датчики, оповещение, пожаротушение', '🔥', '["пожар","сигнализаци","датчик","оповещен","пожаротушен","дым"]', 3), +('IT-инфраструктура', 'Серверы, сеть, компьютеры', '💻', '["сервер","сет","компьютер","ит","инфраструктур","switch","роутер"]', 4), +('Обслуживание', 'Плановое ТО, ремонт, замена', '🔧', '["обслуживан","ремонт","замен","то","планов","срок"]', 5), +('Консультация', 'Вопросы, информация, расчёт стоимости', '💬', '["консультаци","вопрос","информац","стоимост","расчёт","цен"]', 6), +('Другое', 'Прочие обращения', '📋', '[]', 7) +ON CONFLICT DO NOTHING; + +-- Initial response templates +INSERT INTO bot_response_templates (template_key, template_text, description, variables) VALUES +('greeting', 'Здравствуйте! Я {name}, виртуальный помощник AegisOne Engineering. Чем могу помочь?', 'Приветствие нового пользователя', '["name"]'), +('greeting_returning', 'Здравствуйте, {first_name}! У вас открыто обращение #{conv_id}. Чем ещё могу помочь?', 'Приветствие возвращающегося пользователя', '["first_name","conv_id"]'), +('consent_request', 'Для обработки обращения нам нужны контактные данные. Даёте согласие на обработку персональных данных?\n\n[Подробнее о политике]({policy_url})', 'Запрос согласия ФЗ-152', '["policy_url"]'), +('consent_refused', 'Понимаем. Вы можете связаться с нами:\n\n📞 {phone_1}\n📞 {phone_2}\n✉️ {support_email}', 'Ответ при отказе от согласия', '["phone_1","phone_2","support_email"]'), +('name_request', 'Как к вам обращаться?', 'Запрос имени', '[]'), +('contact_request', 'Как с вами связаться?', 'Запрос контактных данных', '[]'), +('inquiry_request', 'Опишите ваш вопрос. Вы можете прикрепить фото или документ (PDF, DOCX, XLSX, TXT).', 'Запрос обращения', '[]'), +('out_of_hours', 'Спасибо за обращение! Сейчас мы не работаем.\n\nНаше рабочее время: Пн-Пт {work_hours_start}:00-{work_hours_end}:00 (МСК).\n\nНо вы можете оставить сообщение — мы ответим как можно быстрее.', 'Сообщение вне рабочего времени', '["work_hours_start","work_hours_end"]'), +('confirmation', 'Спасибо, {first_name}! Обращение #{conv_id} принято.\n\nОтветим как можно быстрее.', 'Подтверждение (рабочее время)', '["first_name","conv_id"]'), +('confirmation_ooh', 'Спасибо, {first_name}! Обращение #{conv_id} принято.\n\nСейчас нерабочее время, но мы ответим как можно быстрее.', 'Подтверждение (вне рабочего времени)', '["first_name","conv_id"]'), +('handoff', 'Передаю обращение оператору. Ожидайте.', 'Передача оператору', '[]'), +('quality_request', 'Обращение #{conv_id} выполнено. Оцените качество нашей работы:\n\n⭐ 1 — Плохо\n⭐⭐ 2\n⭐⭐⭐ 3\n⭐⭐⭐⭐ 4\n⭐⭐⭐⭐⭐ 5 — Отлично', 'Запрос оценки качества', '["conv_id"]'), +('sla_found', 'Объект: {object_name}\nSLA: {service_level}\nСтатус: {status}\nЦена/мес: {price} ₽', 'Результат проверки SLA', '["object_name","service_level","status","price"]'), +('sla_not_found', 'Объект не найден или SLA контракт не оформлен.', 'SLA не найден', '[]'), +('risk_result', 'Risk Score вашего объекта: {score}/100\nУровень: {label}\n\nРекомендуем: {recommendation}', 'Результат Risk Score', '["score","label","recommendation"]'), +('appointment_confirm', 'Запись на визит подтверждена.\n\nДата: {date}\nИнженер: {engineer}', 'Подтверждение записи на визит', '["date","engineer"]'), +('commercial_offer', 'Коммерческое предложение по вашему запросу:\n\n{offer_text}', 'Коммерческое предложение', '["offer_text"]'), +('reminder', 'Напоминаем: плановое ТО объекта {object_name} запланировано на {date}.', 'Напоминание о ТО', '["object_name","date"]'), +('status_update', 'Ваше обращение #{conv_id} — {status}.\nИнженер: {engineer}.', 'Уведомление о статусе', '["conv_id","status","engineer"]'), +('main_menu', 'Выберите действие:', 'Текст главного меню', '[]'), +('emotion_escalate', 'Я понимаю ваше беспокойство. Передаю обращение старшему специалисту. Ожидайте, пожалуйста.', 'Реакция на негативные эмоции', '[]'), +('auto_categorize_confirm', 'Я определил вашу тему как: {category}. Это верно?', 'Подтверждение авто-категоризации', '["category"]') +ON CONFLICT (template_key) DO NOTHING; + +-- Sample knowledge base entries +INSERT INTO bot_knowledge_base (question, answer, category, keywords, active, sort_order) VALUES +('Какие услуги вы оказываете?', 'Мы оказываем услуги по проектированию, монтажу и обслуживанию систем видеонаблюдения, СКУД, пожарной сигнализации и IT-инфраструктуры.', 'Общее', '["услуг","оказыва","монтаж","проект","обслуживан"]', true, 1), +('Как оставить заявку?', 'Нажмите кнопку «Оставить заявку» в главном меню и опишите ваш вопрос.', 'Общее', '["заявк","оставит","как"]', true, 2), +('Какие гарантии?', 'Мы предоставляем гарантию на все выполненные работы и установленное оборудование. Подробности уточняйте у менеджера.', 'Общее', '["гарант","условия"]', true, 3), +('Режим работы?', 'Наш офис работает Пн-Пт с 9:00 до 18:00 (МСК).', 'Общее', '["режим","работ","время","график"]', true, 4), +('Как связаться?', 'Вы можете написать нам здесь, позвонить по телефону 8 (861) 203-33-30 или написать на zakaz@aegisone.ru', 'Общее', '["связат","контакт","телефон","почт"]', true, 5) +ON CONFLICT DO NOTHING; diff --git a/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql b/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql new file mode 100644 index 0000000..b28ee53 --- /dev/null +++ b/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql @@ -0,0 +1,2 @@ +-- Migration v1.5.1: Add recipient_ids column to bot_broadcasts +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; diff --git a/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql b/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql new file mode 100644 index 0000000..afb210a --- /dev/null +++ b/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql @@ -0,0 +1,26 @@ +-- Migration v1.5.2: Role-based menu permissions +CREATE TABLE IF NOT EXISTS role_menu_permissions ( + id SERIAL PRIMARY KEY, + role VARCHAR(20) NOT NULL, + menu_key VARCHAR(50) NOT NULL, + visible BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE(role, menu_key) +); + +-- Default: enable all menus for both roles +INSERT INTO role_menu_permissions (role, menu_key, visible) VALUES + ('engineer', 'tasks', true), + ('engineer', 'reports', true), + ('engineer', 'incidents', true), + ('engineer', 'questionnaire', true), + ('engineer', 'passports', true), + ('engineer', 'customers', true), + ('engineer', 'objects', true), + ('engineer', 'sla', true), + ('engineer', 'tech_access', true), + ('engineer', 'docs', true), + ('technician', 'tasks', true), + ('technician', 'reports', true), + ('technician', 'incidents', true), + ('technician', 'checklist', true) +ON CONFLICT (role, menu_key) DO NOTHING; diff --git a/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql b/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql new file mode 100644 index 0000000..6ef2abe --- /dev/null +++ b/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql @@ -0,0 +1,121 @@ +-- Migration v1.5.5: New tables + seed data +-- Run: psql -U aegisone -d aegisone -f v1.5.5_tables_and_seed.sql + +BEGIN; + +-- ===================== New tables ===================== + +CREATE TABLE IF NOT EXISTS bot_notifications ( + id SERIAL PRIMARY KEY, + type VARCHAR(50) NOT NULL DEFAULT 'info', + recipient_type VARCHAR(20) NOT NULL DEFAULT 'operator', + recipient_max_user_id BIGINT NULL, + title VARCHAR(255) DEFAULT '', + body TEXT DEFAULT '', + related_type VARCHAR(50) DEFAULT '', + related_id INTEGER NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CHECK (recipient_type IN ('operator','level2','client')) +); + +CREATE TABLE IF NOT EXISTS bot_notification_subscriptions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE, + notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE, + notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +CREATE TABLE IF NOT EXISTS bot_risk_questions ( + id SERIAL PRIMARY KEY, + question_key VARCHAR(100) UNIQUE NOT NULL, + question VARCHAR(500) NOT NULL, + weight INTEGER NOT NULL DEFAULT 10, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS bot_tickets ( + id SERIAL PRIMARY KEY, + ticket_number VARCHAR(20) UNIQUE NOT NULL, + user_id INTEGER REFERENCES bot_users(id) ON DELETE SET NULL, + object_id INTEGER NULL, + customer_id INTEGER NULL, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + priority VARCHAR(20) DEFAULT 'normal', + status VARCHAR(20) NOT NULL DEFAULT 'open', + created_by VARCHAR(20) DEFAULT 'bot', + sla_contract_id INTEGER NULL, + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CHECK (priority IN ('low','normal','high','urgent')), + CHECK (status IN ('open','in_progress','resolved','closed')) +); + +CREATE TABLE IF NOT EXISTS bot_ticket_messages ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL, + direction VARCHAR(10) NOT NULL, + text TEXT DEFAULT '', + sender VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_ticket_statuses ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL, + old_status VARCHAR(20) DEFAULT '', + new_status VARCHAR(20) NOT NULL, + changed_by VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ===================== Add recipient_ids to broadcasts if missing ===================== +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; + +-- ===================== Ensure new columns exist on existing tables ===================== +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS recipient_max_user_id BIGINT NULL; +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_type VARCHAR(50) DEFAULT ''; +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_id INTEGER NULL; + +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE; + +-- Drop old subscribe_types column if it exists (replaced by boolean columns) +ALTER TABLE bot_notification_subscriptions DROP COLUMN IF EXISTS subscribe_types; + +-- ===================== Seed: enable all features ===================== +UPDATE bot_features SET enabled = true WHERE enabled = false; + +-- ===================== Seed: risk_questions ===================== +INSERT INTO bot_risk_questions (question_key, question, weight, sort_order, is_active) VALUES + ('no_archive', '📋 Отсутствует архив документации?', 25, 1, true), + ('no_power_backup', '🔋 Нет резервного питания?', 20, 2, true), + ('no_regulations', '📜 Нет регламентов обслуживания?', 15, 3, true), + ('system_failures', '⚠️ Были отказы системы за последний год?', 20, 4, true), + ('no_documentation', '🗂️ Нет схем и паспортов объекта?', 10, 5, true), + ('outdated_equipment', '🕰️ Оборудование старше 5 лет?', 5, 6, true), + ('no_monitoring', '📡 Нет удалённого мониторинга?', 5, 7, true) +ON CONFLICT (question_key) DO NOTHING; + +-- ===================== Seed: default role permissions for new menu items ===================== +INSERT INTO role_menu_permissions (role, menu_key, visible) +SELECT * FROM (VALUES + ('engineer', 'bot_tickets', true), + ('engineer', 'bot_notifications', true), + ('engineer', 'bot_risk_questions', true) +) AS v(role, menu_key, visible) +WHERE NOT EXISTS ( + SELECT 1 FROM role_menu_permissions r + WHERE r.role = v.role AND r.menu_key = v.menu_key +); + +COMMIT; diff --git a/max_bot/sql/migrations/v1.6.0_fixes.sql b/max_bot/sql/migrations/v1.6.0_fixes.sql new file mode 100644 index 0000000..745bd51 --- /dev/null +++ b/max_bot/sql/migrations/v1.6.0_fixes.sql @@ -0,0 +1,38 @@ +-- Migration v1.6.0: Fixes for broadcast recipient_ids + feature keys + categories + engineer permissions + +-- 1. Add recipient_ids column to bot_broadcasts if missing +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; + +-- 2. Fix feature_key auto_notifications → notifications +UPDATE bot_features SET feature_key = 'notifications' WHERE feature_key = 'auto_notifications'; + +-- 3. Fix feature_key commercial_offer → commercial (match code) +UPDATE bot_features SET feature_key = 'commercial' WHERE feature_key = 'commercial_offer'; + +-- 4. Replace categories with new set +DELETE FROM bot_categories; +INSERT INTO bot_categories (name, description, icon_emoji, keywords, sort_order) VALUES +('Аудит безопасности', 'Независимая оценка систем видеонаблюдения, СКУД, ОПС', '🔍', '["аудит","безопасность","оценка","видеонаблюдение","скуд","опс"]', 1), +('Сервисное обслуживание / SLA', 'Абонентское обслуживание по SLA-контракту', '🛡️', '["сервис","sla","абонентский","обслуживание","контракт"]', 2), +('Реагирование на инциденты', 'Срочное реагирование при нештатных ситуациях', '🚨', '["инцидент","реагирование","срочный","нештатный","авария"]', 3), +('Технический надзор', 'Контроль качества при монтаже и пусконаладке', '📐', '["надзор","технический","контроль","качество","монтаж","пусконаладка"]', 4), +('Проектная документация', 'Разработка и согласование проектной документации', '📄', '["проект","документация","разработка","согласование"]', 5), +('Риск-инжиниринг', 'Оценка рисков, BCP, DRP, стратегия безопасности', '⚡', '["риск","инжиниринг","bcp","drp","стратегия","безопасность"]', 6), +('Общий вопрос', 'Консультация или вопрос вне указанных категорий', '💬', '["консультация","вопрос","общий","другое"]', 7) +ON CONFLICT DO NOTHING; + +-- 5. Add bot_* menu permissions for engineer role +INSERT INTO role_menu_permissions (role, menu_key, visible) VALUES + ('engineer', 'bot_analytics', true), + ('engineer', 'bot_users', true), + ('engineer', 'bot_conversations', true), + ('engineer', 'bot_tickets', true), + ('engineer', 'bot_handoffs', true), + ('engineer', 'bot_notifications', true), + ('engineer', 'bot_risk_questions', true), + ('engineer', 'bot_broadcasts', true), + ('engineer', 'bot_storage', true) +ON CONFLICT (role, menu_key) DO NOTHING; + +-- 6. Drop stale view if exists +DROP VIEW IF EXISTS bot_dashboard; diff --git a/max_bot/tests/__init__.py b/max_bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/tests/__pycache__/__init__.cpython-314.pyc b/max_bot/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83e7681ab01547873a5afb6ff517a835511ab233 GIT binary patch literal 152 zcmdPq5CH>>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%hbgxCNeQE zCAGpOvp74ZII|=*J~1^tvp7F5H6}N)B0edzf0-F< zh#YHUTTN3(rH`!?sg`P~w$w;GeC^kak*eDgDNbEet5t1jblPHZ4jj}fv=4ZJU z5*9R5iy<*<<|y}?dCXPnUU^6PE@(uB$4E^~YJQB=f>Ji8RkOm|8nK1#nw`yRi#Wng z%^9xJs*0;{X)dPg*4*&6M?B$btvXzz)iAjuQX8(*>cU>l8?M*t8P6GM2>Uc&_>gvp zwW}ijaHH19Xjh~u9MA$STMt?PibXZ=VLVML9=0x<`W06JL=mtZ`WgV z4(-x89Hkv3=>Gygbo4EUCac;pRgNpNrn`PN6bire(pmBR(5dLq_}8`So2jHGAC;0> zgLX*wB468KdE=Z7QXij+}vSw+(wOzl)EDQC1GuRB@uysWCpY(}qnrZ2R ztmw6{W=WGpLnLRWlNlL?wVszXEt#2Cb?3CKK^LP1OEY6TF$t6ql2rK))p;qG(=EkO zfp1dd*_^z`%)q!v2Ru8!fU2FefT>LYWt1_L?kg48dFRY8N4`?g^IYxjp?mhp3QQihV?eWkP?$EgVJYnB(e5@(o3`8S})9L@71COgT|7(Hl5C(NZG$8bIS z${2H|dzpR3H4c-#Gr*bf`0FdKbC}yNkCA?%e7aS8N5xio?FWq9K`dk>0Z%_Xhg-=} z!p4Vb8e zU75EK4Y6_rIEWlofr_D~s&%#P*`=2YRj2Z&H{9NZ%kN!YxUz8N{VO-?He59e>G#qf z4F2KV@6W9_^{qAaeeCLAxAw1E`ycVd)4XuyQ@iKd$cEjs(DPo;$9Dg^;J+vMw=E$1 zq^z@W<=0of-Zm3c<5%hoOnddoP(S@M4OLq|fkj4=vB{(Dgal>~` zLX`(Fzs~*WIw1+*HIbjv85*S(7co%;Js1J^4AU8%N+2MJbcXYUueyr5E(@98^4>Jb6TTTEC6ett%g;?hdX# z8@_)y!WcWCK-gy>fdwsuS?u3{*l)2hX0v6B1$(RbwFB*w+hdHV0OTx33%kWTvCz6Y(|V{)9Iu(8l(!^XOL%*)g9yD z0*e=m;st?84T8iqR6)r=VgWuj#z`^Sk|pnn?caCvN}3+Y4{M|MpGG zhRb`S3V|Fzj`90OJq?|%whgq>KhhstZ}C9<$vnWDK6C)JL0~Gy0I@)x`7IE3~mf~uvI<4+66cU zF;*egRKzdh%ub)!#_EI1Tlo6O#aaq3=wOgV!Fr@3%%X7H#K<#XClKLSQyz5Z6^l9O zQk?)m7zN|a3A7PbD!t%(DcWa_n}Ar^C^2x`QK1Cm1A%MW16)4Fmw{`F!fzLF_h`0! zPZ%)fD6OILE?o%#dJ_Zv^8os;BIr#&6!ar6?t;AeTfnG=`Th@{NqG{+QI0|N7))uq6?dl>%iyNCb&^oGlG!-}pd zOo82ehfS**)09_eax$YEM; za3I}ILpEW#;|ZWwY!qvUIn-%esivVi*Gi2Y=>Ugyixs4C7_nS1YC|8q^BXW>h_8!= zT#2D_yLcE&_F^}cGj_y>!9K2;L_(DM5P4 z61=pmSF5v=YFtT9%Hkz?aweO-fd4%qx))qSa%#TBgiD^Ky|qPlY5W;UoxxZ-4S6ba zEyd{9i3|}_DhVQtD2#Xl9(55aOquF#9M2DK`q~zS#l-E)1>cGMU_tP1cJ(eL?hY)*;)jFdT9Y zY(2k5yq^)*1Jd@8^n6CD{z<$KN%uqY${Km)-{jZ>((-_`e@dL|#CMPQ3dH|__~7p0 ztnW5oZT@YtKwGyto>uShglpSEYRgGD&pK_mM;m?z;v?Ifi8i7*xP{b~#}Kd4hA%_c3%(4A)B8g>YG}9a8`FZ@m8WliPBQ5Pl_T%YDJ1_QsfGdw{JuasqvB= zvU{jq>7v6LvEx1afK_6{QGf*I84KSEUij9)!Z{%80B0mI93;RDcfv6`8!Uo22n^(p zvvke{_eYT5tLm!$nAQv_X|*S9H-^PmRj*!GRad=w_3BmCD@~1c3ZB3C%XPn=e@Rg$ z$RnOA8+h0c;9ccSMT-n5nF#w|(O%%@?$KU-RO=oZ85kY7 zG&JJLO@vcX9{xFi1oHE1$cv_AD&DNhRBl$%$lVqtQ>9hFjnbPl)mjzY8Z8P})vDpv zYBg}{G!<^WRtvX5tApF9)x&Ml8sNsXM!4&=Cb-R74DNbu9o!9CGu(~Zdbpdk4RAMW z8{uxzHo@JhZHC*TZGpQ@+Y0w3tp)COZChr?n3CD4y@axFw`xX{W(K955XGTV#qd|D zDC)zDaNkv~Q5buM?Wz-g`X5(1ZSO8OrubMc)cMGpk%f3%8LQ~D-!@-)ty&-qs{(TV zFZ7I6b>V9-T=|wA*c+L?tSH-+Ypj`2;BbS3FCcvChOy}3h}JmXdAOp}&Wje&Dy}sN zoZ)o>a{fDIIflULxbIEt9ZUhmU1b)f5_z3O1NP)}qe$ia4@y6Xd}@`h5(cIli-`AW zRb_Eyjcd`exRsJ_wN~>b)R+9?;yr7uucGv*uNqA)Tdpecz5~_!Bn?HGuhwd{y0Pj5 z5l?8%wJqW+xhQWHVa|WF=Nds-{J#;=>W@d}BlxSpU*++LF{;(?R_-_b9kqO`a{ksq z1AU7@hsfJW&QWr@4C-W7)%>lFgTIU=OfzX@TEDp$-a~CB3qCY-Kl00plPB9Ex8I_O zqits0cWt2cB*O(N4+3BZ9c(k(r;{1oBo0@1XD=n_Y%|;H?zEodi8_G7QxRk*>q4@D za~FH-?zA=XEZ0uVBol_7n3=I^B|$sWglW~CnZK0*ThSAS)vQkdQ8$wF#zcB{K4B!S z21M)E7gIBt)SPKm&!mB2T3dDVMtX5(lA-F;GwJJz8GSA>o3v_mGclDU3RY7$tBzE9 zuGcWqhP6STN*l9@jGlaFekL)O$ROsvYE_xZnJKHD-oxv!!|gfj_8f70j=4Rr@{IBq zbaZ%phfQKuds_Kp9Xa>fwX2D_$>cldQs!IN%v2_+Cz8`CGd-8QHk)`yzn;!qqZ%=p zt52D;?en*+^{kRobE!-UWo6&R+Gg|P_TsoGUTOYkIPWT-tM%_U{!Zf$VxPy>zklcZ zce3iidzaz7cjw;a&tkg(ebDuzlRrEOVCiHocCet@zr1=d8`~wl68M2B!DaQJ@U0L< z9)X1SE=#bO22elfa?=n#L@b?@5k3IE4RA~ zfmr}Cux0y~M+k9$CxpYq@9nT85v3lRoS|)#v*dp4UJyeuQH@4QHf_NHx zt~r#6#rdAK3>5}R%5Cc$&TH&P03RFL0a;Na2_DEfF161mWxI8Z?J}a)dJn5*VbtZ! z{|%=cdj-p_{hivo@gFouPW#*n*~MIzMj7x|_2Gw@Z9Y8}5AjAVA_VFAG6MHPe5``0 zcMgmG0QF9dPiYdsRUsDFja5RuQ$N1mrQRW>iR7rOQ176%d(<<5g0#bPL>?z07kJW1 zet|l*AM_A8Mxd`&YE@9YtR-)&Q$3aAD{}HO9GJ&Iz7o}{T?&uDbdCb=K&3}%QbmfG zu_~V`CAtPxN>p$-@gJ&`TBuJBX|>Q-HZZZ%iej#8WSI16Rijrg&Sba%N}3ZUh5oY1 zr8t3pgh{1R`0OO%lc_kk;sa_BuJ-`oC{Wtn5}{Oo7*r!xUB*bvVV)0tgTu;J%en4X z8?BVlSIK#q9DHqs>mIne!PZJ}&#NYVX0B}DlI14G;%+8pQj@xq5o?Es+xep@-{v2~ zaWlT=jsJY-Kijz!S$g%e=2zaU{=6m5<;$$vo`s+pYcHtwFR!-0cT@T#;0jv~#sDcu zt%3}Ygl~ntL&mrPV1+CTpUSOWXEXVbWfieJu-E5+Wi>9&y)~D9drnW@OwMuE#rr>- zI7_}|bm5zOnbp44F)NRbmN6-VS`6n-Ym%#8L$V^4^9{ywQgeQ;0(EU>(U>!#qlKz= zpN9jL!KO*I`A2YE4C!wrLpuFg^I^u2{&2^?*pXHD+}n|h?J21CFR$()&f^4d*cG-M zi~&l-f9!-n6229Bg^X|mKq_WNa*6EE@$H_T{6zD``3fzv&=FV0cS|`6tB4DuW6=xB z!jzCgASpqi0y#u>h@b^DNJ@=k)sU2$#(Q0o666ur7#}EMb>hR&!`Kd&Mw~ZENrvN) z8>9dfd9@e9ob+;bQ93=kHpxw8(m9eAB$sly&aAMeEUq&vRLkPZnU+>t7FWtRzJ;h7 ztxl^StMN%s>a`7`mRyvBoXnSAl9 zqs_cu@5~~tCU5Fh%U-oy_9BUk%U-()`X(H{!ecKO9W=fngX|@sL!Kp|PLrlkVhN~~ z3uapxm-OZgMO;i;tsc%8fWefA{{|#o+z=DbLM)z(#k1;>CFYC+%EsbL@y}xW0e-xZ zJFz1L)&AuPVZZcBfV6rx%CdSy_*NJ>9)pxiaR(fipri+kk2gv(J^)lhHtA)^A)7>d zb{4P(5oF{OAoD1Y!gcHOzT7uZd6jsHPR=gD7U?{}D z%ttehQ^Yt_gOCW+ zB`E13_$QlqaIp)j#f*a%iv2?;+WX>8ae(*R)Ry6evY$8=3UA z$s>KORZq1#U69PP82f$FS2lV=rfeP@wxUA* zl5BSsI>6P{t&}Psvu=yhqi$hzRJL4>q*&w8v(;$zSnVm66lukhJub?DD>!38l44_! zr0A^G3|9&2&VPHp(&V47xcXDT3eXexr_Eu1q8XQ?hh2rvHtbKz*z?k#VkP<$86Wtb zC+<&M!u}NYCu&q;e0#yhr*$RzQ^5N66ZfaBVSfty(+k|6noIPjRw}pmdE)-m685LC zKT(@{LC2@{CHfN?2>6~S?oZpo{uK5nYE&<1f7(!@KYhd3nc#im{`6AVpThq1eD)`; zk@f*QE5T(}IM{X$X0AdSdhd!btmoK5ap#cLNIUoRc_V!@HJO~W8Yj|oChT7H=@iUg ztab2a5)&B*VO?r&VKHUgazdIXuuX9~jUa61OHO1!rD_uU>DUGmtTlgcZEMYrncO4R znZ+JRxpR`XTiWZ_v>noK4H&uo8_zliJ=lWYJ2dKSXhAi66`2_A?)i!{F)(!b%7Av& zsR`^TdiFJe(*e$3LM}#nFZT9ef6-UbD$yqT2DHI$I-h{$;O4V#mA2v82FEtTWq8&O zCb3%63@heq;4Hx1*sPfE!vn+n5v&Gxg&1V_c7K%?A05o%!=P0&kDT-qYRVsc1@ z47kV%A0m()8RG+xS=u$hN;OEO8eGDzn8)Mp;$|g>Qw3@(3iPQV|ZoCG2yv!D4bqg$%gJ2_GVm9U0>Tkl7c!RKxVGhO_a* zaQVmT1oWv|=o-#-4O0|=*lDI!rq1>?{iUJTDDya4^ zj}S8Qa6&jtU&szyR)>Udg^2P9B&6>}I7B0kZ!X{>dPs&GxabKVB9I>$;{%Xe`Mr)& zxsEMuhQo`s7tlxjKOVdfj&bZmFpOgb)&AwxW7*hV>6HML!bVwEj|txjQRERwNM#}% zfJ>VtSWFJ7KI-T3a6wM^5b+`Sg_9dM47?JSE&mbrEl?-85M+bwTGJ1(bty52 zE@O|OH8C5RSkW9>?#Bw=y%b=f?7WFae&4eS3uPyLpw)z93*pk6kS%qYbS?{J_b#x& zcX-zH%h7eO>6gRx+Q>Zpx-GJC@!21h$)D4@Ty;w}GG)@Oc}<^o@UzE7Ibcm4))lL^ zkzsS_b$oAHOiXo+oLtj<+89I5jBz-8ubOckAndfkDHOh^!`VDLL1Ao*2etLp z3faaDAO}gbwhf{^Pq#DAWm=yNVgO19h(4r~T-;Kv`^IhbO(D7GoTKUC7~!w4~*@Tgc(p zZngC#aCm3rAC`8sV0ZSj3M@vO!KcIs~6_D5@P8kse{1Qw4qP77CMG<9ZUxS zz^{YY@0)bP!7~adf8A@RNh40U29oQWJkvM0KB&ZhgWbQS-6G?4!?C$jl$tEI!r*2(P>coJjt1IU{vv`Md*fYba z{c~Ym@QizAsj~-KB}0(F6WIOmo>{zCI_#NY&kTE}>|wZ~jGqcx>J)>u?Gkm`SMd5W z@0rEBuEU<`)Lf_zSB8V@V6CU@e7W`-&qA6lbsoYuTpig4pLonH-oG98%&=#MW2URC z*<)s@lNI*3(4SV%EZzwo_RO$nhCS2O)$E>G>WqfHF7&6>Gq;DA`TOX!(9!k!uS%;&RbGPA3l+>UwuR5F3PaFV)#OTcw=B5foecX`~B zw9J_VyBU1Zv^EO*^Ec8kPr`-hX(MB85TS|r`E+V71FNNsgSxPYOTsd@bOVQ}I7D$1 z`)tyfz^&s>*z7Gmef`&Q(28j_(@Qg{iMMcd?Sz3s@k3W|#>Q_xYuoC+-tN&W8t(8N z7A9rQJ@j8;K1MEf>m#Gc(+JDQGq*Jt^)cLkY4j3~KN-HH(Z$A*+rRZyeVOjz z;Y$NUqcGd!8Trbp>>g}B1)kd6ZSbZ_CcE4kOMP!xdJm4a&j@(76?m0k&u<&llDtX>hm z6(Y(bkdW+<2#09Ik2r7@d3zfSXTQ;ovZX+uV&+~!sXu;x4mEe zw7+oTYVO2UiUJV3T2Sp@9wBrOyc5D<`jU3ovU*kcR){E%KtlSighMpquR3rMJtRX8 zT=aww5y+2>@d3zf$?rN&WgXAP(Q?OmSyAfoPrC}o$8*PVAJ%vlKn%B4*}ptOV#ucx z!eJ_%9k#5F3*QP6F0xqJ5WON9TLr?e+f&9oAAAlE^Fn*m3k9UAT5+3j1R*8k%8$tyuZx6$d zcTmLBJz6U~ctv*3uxCDxJ(DR|oB8q7?34%URa}R_ZceA}!tP5qXdTD!9#8E)PU{v$ z(^|Z78_pwk9sMrFvop1rM&e0#9mUzMBcY(B)2rom>0*Crn`iS;Db%aToB2hFV;MSg zEuFZbSMEVNMS|EVIz#akm&0&|;whY4xbIYM-zk7u0RHyroaH1Uma>7T~iSBdoBnk(RSA z2%ODqx7GS^-7s7?1Xm1K^r;e*#}qi9?rtmIGdF}iGwhkvCPQ1BGClLz?Y81Qb7R;u z!=4%TOsWBTN{pG$ZnqWhnVZ6%8TL$Slh5Cn$&@skxfNXFOfqpZiBncgGco=625{;` zVrJ%gV&W}>6gO6_G=|_hobMoz9UsGv+2ksn^+Bd3R>P$$qxz*jz5mh`ZG`J~l9YJ0 zx}A1HW`}DsmC&OOnAyx!J?X`ao}SWgq!$g7AG5jBqu(itr*fD-W*0%&8V;XDM|-ZN zJ+B^+N}U($G9C93@&1zblKQH5X&W4_zS;-q6SZ)#H+QfXU=~2Ex1id;JVIjoq*nr@ zzOqr4)n4ISA)-722}ylLH~^QnNwAn4QXvB_a>9oQWJkvM0A$vqzVes!IF7ls$AxY!5FbPKoWIQ(}8^9X$ZE0Af7_)&AuX z62q~sP6&soXm;4L+9P}`M3hG$Ar+Kx0MaS3Hdss!spwo-hn(;s0@;x{%(vAo+rGg3L5|5)+6i5rF{)g z#YL@ry-&q;37S@PD!n-O*!Ou_wR7o=&Phl!MB_WC;kH15)2e;_tUl7)t@ZRjhD9P% zaX(5|y^-mv-@2}w$%HX+gJ;Ux?$K2HVh!Z8r17DD=izD6_MN7y>`!xTAFi@Ljl1Lb zpU&++4KNEJcDkV2zdS%s|ZlT0-47$KX5j8@GQVAfY{lB zYX9;GA^QO*gu^6>*anDqC5f#NiHKCfMh>lgT>^KiYx{ka>9oQWJkvM0A$u9 zf%;2&f=YT~sRa%%=_`OfdgsS??%x6M;hkLUL_xKGdG$m#_KNgMfC^@#EUPDkZ-prG z2qdIp5)Qzn76}%UL#mJ7;qh=mPWTY<;T;*_1AtdT`V6m1ZUFH_|s#laD{t@ez)#-UVJ9YQ?(6=!WCLFv#(Njg3{SUCDctHf0?f)9ivw z&7|M9wtB`gSIoO;y~YALufUnWU?!htKrjPTABK#M>Lg@zvSqN^7^@I^X|392conG2 z9t*Ez{#Cn@ncEpW?$&jtE`Bs~Gvw)c+)nvP8Col2Xmms7qM0=4^id+n7@U_YyLeP*UiX(>t46_D^tG?>-O(5b8nY%S%tf=b2zSuiSsVSg&A;{iLGRb zlsL0lEv8@M1`=we>=_O?ilhWzcFxfbRGW(OxD)}-o2Z5R9w-82h~Ry`n=YO78zrg) zg3?1pM{9az(s@?f%i+5DayeW#UoMARCCaR+TCI!YKdrhgQeUpIZi~{}>d$iJTI*@* z#LU>^`f9*Y+1?r$%hW$dL1-BLY9LG&MPs)b0AxlQ-#{ z>0O?IpcsM9Q!)QL92ftW3Yznzwm45}i}O-#aUQCI1Lt!G&Lc1jAa=f>+P^$PNOR$Y zaF}!zcG$9dUiemsD33rw(p(S@K+;^;U@H%3M0jck zMvist)Q-^X82uqsT|AGf3r8Xu4v@s2<2#ah&kWDg3411LAnci}5=xy7@a#_Q;5{=u zPY0hQ95bKCp2=og8~ND)waLj;MxRUI@)?>ZKE^SQRs$n3k)F#WCNj1!HMg*sGH&rj zfH#S-PR@7Wv^BeCMf)j~&5GC-9yUp0+xu5LKbj;0B~2?|V!AVy$0;K{t0SXRDPtC^ z9O*f0w`aB#jPyQUVg5BT>7FsUHD>Lc^RK52f~N9KopxsE;rW&yszMNaq-f$Yc_AArn$O-hOyE}j|FA+2n!YfsIuz|1&UlCN;~lLVa+ z)M@K%J=$4245gFLBlNfdnpM){F2vnq+B<<}xw#Wkr%9S$_j3ee#?Zwl1PIWs1ql@ocLe94#7 zZBEGq^avepg;sgZd2e7kmkzh8EUu%&jh4lAbhy=8&6iMLMS9gW)>lz_=w)j(wQRYh zzWG4)K1oAS=Bu?@t!}LPK*SSTb8UzCMlQ+$CrP3`*N87K{zETY@72p1qfR>sT6I9* zs0jOpsB!*3>>G;m_2?T~{cgpeHN4=BLIx9IClGIJB4;#VTGIX57Wo`CIt}^@{rT&W&Hkm?%a|^Iyt2PhAaaXbR@89`8EENylyA0>OJNGVs z7TX2rgRUQ){NYIeODA(A$l1TVdN3Q?CA|_LNzO)DRu2l_3Q^<{NOe^G1^kd5G8~tpw3tHMqbC% zyjIz%XzFgIt=_6g%`pXm6@~E$ZlA3O6@!X@dspjh$~0jz*h*`J=Oqi`?T4_bLv&}T9vWMJSv1!NMH{R?0n+;p@+xO=->@PID z!GHONH|`!mm<*7>-6IlQCM4;TK(^rxcS>Nnz5k`*jgP z*}Suu+sNtQ-8C7;FkbX0it;x2kg9YkC?D9+`Bezr>mU?aHfST!?JHX^cJQZ zezTAnt6@CvJ;6o5^Tt^pcg!CG`?%ILrh@0i#?SjWo$S%WfxS|c!^P>GHw8}d#$;a; zd9*p5ldeo3m7H!ZdE=bUc}HO0ocb4hK<1HiIwyT#9*ffxr_-V?6Oq7lW77BpMwDgV zF;*?|hG||oTsg%#5cP1|T35=z3H2CpTXn1wZtYkV+`2=~d+R-%w_&W-$9e0nwTSlV zdQb3lxK5Nt{I`u`R7}L8Fp8N55B@#Qg8}`i&5awujn^^8$=Pu=OaZ5p8P1Ps_-D-3 zxqiorwBN33-G8d}_SV*v*@_MCt%=r~xPg4K^=vvbLcIKTvxh-+bAr zgSku-8&IrhDwCWw8UMHHUFcRd$1Xh{7=J~T;EVwE&In*tGZPupin9O4e z!p}UYJi!;;7k=8}jre(a8Xs)ssf%cc2s)@k^i1kcgLJ~WQ<*29?$E6mzwAJ zZ?AoID(Blg^rIYC=zBGDhn9B3d>w*{tgmGZ3pLQxlnqB-**&7O@&#fk=i{s+?i{ z!Jv_wIV822zYpjC{x)Qv2Nf03jt6RG^w@*CDnJdD(E|j1>p??B^ymY%B6|9PszzUX zu(2}Q8=;uj2&WrhWpn^;b96NFU{91nnrfrxA`do4qbD9TMWYuG5RIOE&?FL`Ad>x& z2Wm}pF!Er1HQu(aB6>CQ3$-fxdgNycx$Hx+Y(lJ6uP>%%u;0Zrh;dsj`ur{G)a_iB zgt*skB;Pi`kgQr6WYySVj1gHiiTU~VDRY(!tR(R;HpXjXg}kqjvzO?rCwOx>W&96# lsp?D;Q_&8|x%#3?Ds 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 DRKpe) literal 0 HcmV?d00001 diff --git a/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76e3165cb1ad7623fb2daa5ba9bc1c7fa6f57ff7 GIT binary patch literal 31991 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