v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.pytest_cache/
.env
logs/
*.tar.gz
+128
View File
@@ -0,0 +1,128 @@
# MAX Bot Fixes — Implementation Plan v2
## Phase 1: py_service (no rebuild, restart only)
### 1a. Fix ctx() mojibake
**File:** `py_service/app/routers/service_pages.py:48`
```python
# Add to ctx() dict:
"page_name": kw.get("active_page", "")
```
### 1b. Add broadcast page route
**File:** `py_service/app/routers/service_pages.py` — after line 1396:
```python
@router.get("/service/bot-settings/broadcasts")
async def bot_broadcasts_page(request, db, user):
return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки"))
```
Plus proxy endpoints for broadcast data.
### 1c. Add sidebar link
**File:** `py_service/app/templates/_sidebar.html` — after bot-storage line:
```html
{% if role_permissions.get('bot_broadcasts', True) != False %}<a href="/service/bot-settings/broadcasts" class="{% if active_page == 'bot-broadcasts' %}active{% endif %}">Рассылки</a>{% endif %}
```
### 1d. Create bot_broadcasts.html template
**File:** `py_service/app/templates/pages/bot_broadcasts.html`
Basically the broadcast section extracted from bot_analytics.html into its own page.
### 1e. Add try/except to all proxy endpoints
**File:** `py_service/app/routers/service_pages.py`
Create helper:
```python
async def _proxy(method, path, json_body=None):
try:
async with aiohttp.ClientSession() as session:
async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp:
return JSONResponse(await resp.json())
except Exception as e:
return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503)
```
### 1f. Update permission_config.py
Add ALL missing routes (bot broadcasts, notifications, handoffs, risk-questions, storage, tickets)
## Phase 2: max_bot (rebuild required)
### 2a. Fix feature_key auto_notifications → notifications
**File:** `max_bot/sql/bot_schema.sql:156` — change `'auto_notifications'` to `'notifications'`
### 2b. Fix commercial_offer → commercial (or vice versa)
**File:** `max_bot/sql/bot_schema.sql:153` — change `'commercial_offer'` to `'commercial'`
**File:** `max_bot/app/keyboards/inline.py:20` — ensure payload matches
### 2c. Fix test-run: add role:'bot' to test messages
**File:** `max_bot/app/max_api.py:71` — add `msg["role"] = "bot"`
**File:** `max_bot/app/main.py:407` — add `role: 'bot'` when copying greeting
### 2d. Fix flush→commit everywhere
**Files:** `max_bot/app/main.py`
- Lines 596, 620, 632, 644 → `db.commit()`
- Lines 722, 725 → `db.commit()`
- Lines 740, 755 → `db.commit()`
- Lines 801, 814 → `db.commit()`
- Line 852 → `db.commit()`
**File:** `max_bot/app/database.py:15-17` — add session.commit():
```python
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
```
### 2e. SQL migration: add recipient_ids to bot_broadcasts
**File:** `max_bot/sql/migrations/v1.6.0_fixes.sql`
```sql
ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb;
```
### 2f. FSM persistence (optional)
Create `bot_fsm_states` table + async save/load in FSM class.
### 2g. RMP permissions for engineer (optional)
Add INSERT for engineer with bot_* menu keys.
## Phase 3: Replace categories
### 3a. SQL migration:
```sql
DELETE FROM bot_categories;
INSERT INTO bot_categories (name, description, icon_emoji, keywords, sort_order) VALUES
('Аудит безопасности', 'Независимая оценка систем видеонаблюдения, СКУД, ОПС', '🔍', '["аудит","безопасность","оценка","видеонаблюдение","скуд","опс"]', 1),
('Сервисное обслуживание / SLA', 'Абонентское обслуживание по SLA-контракту', '🛡️', '["сервис","sla","абонентский","обслуживание","контракт"]', 2),
('Реагирование на инциденты', 'Срочное реагирование при нештатных ситуациях', '🚨', '["инцидент","реагирование","срочный","нештатный","авария"]', 3),
('Технический надзор', 'Контроль качества при монтаже и пусконаладке', '📐', '["надзор","технический","контроль","качество","монтаж","пусконаладка"]', 4),
('Проектная документация', 'Разработка и согласование проектной документации', '📄', '["проект","документация","разработка","согласование"]', 5),
('Риск-инжиниринг', 'Оценка рисков, BCP, DRP, стратегия безопасности', '', '["риск","инжиниринг","bcp","drp","стратегия","безопасность"]', 6),
('Общий вопрос', 'Консультация или вопрос вне указанных категорий', '💬', '["консультация","вопрос","общий","другое"]', 7);
```
## Phase 4: Deploy scripts
### 4a. Fix max_bot_deploy.sh rsync
**File:** `max_bot/max_bot_deploy.sh:32` — change `max_bot/` to `.`
### 4b. Add max_bot hook to main deploy.sh
**File:** `py_service/deploy.sh` — after docker compose up -d:
```bash
echo "Deploying max_bot..."
bash max_bot/max_bot_deploy.sh
```
## Phase 5: Changelog
Create `CHANGELOG.md` with all fixes.
## Deploy Order
1. SQL migrations on DB
2. scp + docker exec tee for py_service files
3. docker compose restart aegisone-app
4. scp + docker compose build for max_bot
5. docker compose up -d aegisone-max-bot
6. Verify health checks
+23
View File
@@ -0,0 +1,23 @@
FROM php:8.1-fpm
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libzip-dev \
unzip \
git \
&& docker-php-ext-install pdo pdo_pgsql pgsql zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY src/ /var/www/html/
RUN mkdir -p /var/www/html/service/permissions && chmod 777 /var/www/html/service/permissions
RUN if [ -f composer.json ]; then composer install --no-dev --no-interaction 2>/dev/null || true; fi
EXPOSE 9000
CMD ["php-fpm"]
+28
View File
@@ -0,0 +1,28 @@
services:
nginx:
image: nginx:alpine
container_name: aegisone-php-nginx
network_mode: host
volumes:
- ./src:/var/www/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
restart: unless-stopped
php:
build:
context: .
network: host
container_name: aegisone-php-fpm
network_mode: host
volumes:
- ./src:/var/www/html
environment:
DB_TYPE: pgsql
DB_HOST: localhost
DB_PORT: "5432"
DB_NAME: aegisone
DB_USER: aegisone
DB_PASS: aegisone_pass
restart: unless-stopped
+29
View File
@@ -0,0 +1,29 @@
server {
listen 8080;
server_name localhost;
root /var/www/html;
index index.php index.html;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param PHP_VALUE "upload_max_filesize = 50M \n post_max_size = 50M";
}
location ~ /\.ht {
deny all;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|webp|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ define('YANDEX_MAP_ADDRESS', '350072, Россия, Краснодарский
// MAX_LINK — (опционально) резервный мессенджер // MAX_LINK — (опционально) резервный мессенджер
define('WHATSAPP_LINK', 'https://wa.me/79531137616'); define('WHATSAPP_LINK', 'https://wa.me/79531137616');
define('TELEGRAM_LINK', 'https://t.me/+79531137616'); define('TELEGRAM_LINK', 'https://t.me/+79531137616');
define('MAX_LINK', ''); define('MAX_LINK', 'https://max.ru/id2311381465_bot');
// ============================ // ============================
// 6. НАСТРОЙКИ ПОЧТЫ // 6. НАСТРОЙКИ ПОЧТЫ
+49
View File
@@ -0,0 +1,49 @@
services:
server:
image: gitea/gitea:latest
container_name: gitea
network_mode: host
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: localhost:5433
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: gitea_password
GITEA__server__DOMAIN: git.aegisone.ru
GITEA__server__HTTP_PORT: 3000
GITEA__server__ROOT_URL: https://git.aegisone.ru/
GITEA__server__SSH_PORT: 2222
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: gitea-db
network_mode: host
environment:
POSTGRES_USER: gitea
POSTGRES_PASSWORD: gitea_password
POSTGRES_DB: gitea
PGPORT: 5433
volumes:
- gitea_db_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -h localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
gitea_data:
gitea_db_data:
+1 -1
View File
@@ -154,7 +154,7 @@ require __DIR__ . '/inc/header.php';
$dsn_cases = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET; $dsn_cases = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
} }
$pdo_cases = new PDO($dsn_cases, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); $pdo_cases = new PDO($dsn_cases, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$stmt_cases = $pdo_cases->query("SELECT title, text, effect FROM cases WHERE is_active=1 ORDER BY sort_order ASC, id ASC"); $stmt_cases = $pdo_cases->query("SELECT title, text, effect FROM cases WHERE is_active ORDER BY sort_order ASC, id ASC");
$cases = $stmt_cases->fetchAll(); $cases = $stmt_cases->fetchAll();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$cases = []; $cases = [];
BIN
View File
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
DATABASE_URL=postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone
APP_ENV=production
LOG_LEVEL=info
WEBHOOK_SECRET=change-me-to-random-secret
WEBHOOK_PATH=/webhook
+22 -23
View File
@@ -1,24 +1,23 @@
# Changelog # Changelog — AegisOne MAX Bot
## v1.6.0 (2026-05-24) ## 1.7.0 (29.05.2026)
### Новые функции
### Bug Fixes - **Рефакторинг архитектуры:** плоская структура модулей (models.py, yandex_gpt.py, keyboards.py, email_sender.py, config_reader.py) вместо вложенных пакетов
- **Features**: Исправлен feature_key `auto_notifications``notifications` (фича теперь отображается в меню) - **VCF + User Model (Q2):**
- **Features**: Исправлен feature_key `commercial_offer``commercial` (унификация с кодом) - BotUser расширен: patronymic, email, organization, address, vcf_raw, contact_hash, phone_verified, email_verified
- **Заявки**: Исправлена критическая ошибка — `db.flush()` вместо `db.commit()` во всех write-эндпоинтах (данные не сохранялись) - VCF-парсер: FN, N, TEL, EMAIL, ORG, ADR — все поля сохраняются
- **Заявки**: Кнопка "Создать" теперь корректно сохраняет заявку в БД - Upsert пользователя при повторном контакте (greeting.py)
- **Тест-прогон**: Исправлено — тестовые сообщения бота теперь содержат `role: "bot"`, отображаются в результате - total_conversations инкремент, last_interaction обновление
- **Router proxy**: Добавлен `try/except` во все прокси-эндпоинты (500 → 503 с сообщением) - handle_contact_share сохраняет все VCF-поля (handoff.py)
- **Mojibake**: Исправлена кириллица на всех страницах бота (добавлен `page_name` в `ctx()`) - total_tickets инкремент в _finalize_ticket (inquiry.py)
- **Broadcast**: Добавлена колонка `recipient_ids` в таблицу `bot_broadcasts` - **NLP history context:** _get_user_history_context() — последние 10 сообщений + открытые заявки → YandexGPT
- **Broadcast с превью и таймером (Q1):** модалка подтверждения с таймером 10 секунд
### New Features - **CSS-переменные (Q3):** var(--white)→var(--bg-card), var(--text)→var(--text-primary)
- **Рассылки**: Добавлена отдельная страница **Рассылки** в меню Чат-бот Max (роут + шаблон + sidebar) - **bot_settings (Q4):** showNotification(), max_bot_id редактируемый
- **Категории**: Полностью обновлены категории обращений (7 новых с иконками и описаниями) - **Webhook secret (Q5):** проброс X-Max-Bot-Api-Secret через прокси
- **Permissions**: Добавлены `bot_*` права для роли `engineer` в `role_menu_permissions` - Маскировка sensitive API-ключей в /api/bot/settings
- **Deploy**: Улучшен `max_bot_deploy.sh` — теперь использует `docker exec -i` pipe вместо `docker cp` - dialog_cleared handler — сброс состояния диалога
- **Deploy**: Главный `deploy.sh` теперь включает развёртывание max_bot - Миграции через _add_column_if_not_exists() — новые колонки BotUser
- Rate limit в broadcast: asyncio.sleep(0.5) после каждых 10
### Infrastructure - Уточнение сообщения (contact.py) с учётом conv.intent
- **Database**: `get_db()` теперь корректно вызывает `session.commit()` при выходе (аналогично py_service) - Тесты: test_yandex_gpt.py, test_email_sender.py
- **Permission config**: Добавлены все пропущенные маршруты в `ROUTE_PERMISSIONS`
+4 -6
View File
@@ -2,15 +2,13 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# sendmail not available on trixie/slim; email sender falls back to debug log
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/uploads/bot
RUN python -m pytest tests/ -v --tb=short || true
EXPOSE 8002 EXPOSE 8002
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--workers", "2"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-268
View File
@@ -1,268 +0,0 @@
import json
import logging
from typing import Optional
from app.max_api import MaxAPIClient
from app.settings_cache import SettingsCache
from app.fsm import BotState, FSM
from app.conversation import (
get_or_create_user, add_message,
get_template_rendered, find_in_knowledge_base
)
from app.utils.emotion_detector import detect_emotion
logger = logging.getLogger(__name__)
async def handle_update(bot, update: dict, db, max_api: MaxAPIClient):
update_type = update.get("update_type", "")
user_data = update.get("user", {})
max_user_id = user_data.get("user_id")
if not max_user_id:
return
await SettingsCache.refresh(db)
if update_type == "bot_started":
await _handle_bot_started(bot, user_data, db, max_api)
return
if update_type == "message_created":
await _handle_message_created(bot, user_data, update, db, max_api)
return
if update_type == "message_callback":
await _handle_callback(bot, user_data, update, db, max_api)
return
async def _handle_bot_started(bot, user_data, db, max_api):
await SettingsCache.refresh(db)
if not SettingsCache.is_work_hours():
from app.handlers.out_of_hours import handle_out_of_hours
await handle_out_of_hours(bot, user_data, db, max_api)
return
from app.handlers.greeting import handle_start
await handle_start(bot, user_data, db, max_api)
async def _handle_message_created(bot, user_data, update, db, max_api):
max_user_id = user_data["user_id"]
message = update.get("message", {})
body = message.get("body", {})
text = body.get("text", "").strip()
attachments = body.get("attachments", [])
user_data["text"] = text
user_data["ip"] = update.get("ip", "")
has_attachment = len(attachments) > 0
attachment_type = ""
attachment_path = ""
if has_attachment:
att = attachments[0]
attachment_type = att.get("type", "")
if attachment_type == "contact":
payload = att.get("payload", {})
vcf_info = payload.get("vcf_info", "")
import re
phone_match = re.search(r'TEL[^:]*:(\+?\d+)', vcf_info)
name_match = re.search(r'FN:(.+)', vcf_info)
if phone_match:
user_data["phone"] = phone_match.group(1)
if name_match:
user_data["first_name"] = name_match.group(1).strip()
await _handle_contact_shared(bot, user_data, db, max_api)
return
attachment_path = att.get("url", "")
user_data["has_attachment"] = has_attachment
user_data["attachment_type"] = attachment_type
user_data["attachment_path"] = attachment_path
state = FSM.get_state(max_user_id)
if state == BotState.IDLE:
if text.lower() in ("/start", "/help"):
if not SettingsCache.is_work_hours():
from app.handlers.out_of_hours import handle_out_of_hours
await handle_out_of_hours(bot, user_data, db, max_api)
return
from app.handlers.greeting import handle_start
await handle_start(bot, user_data, db, max_api)
return
if not SettingsCache.is_work_hours():
from app.handlers.out_of_hours import handle_out_of_hours
await handle_out_of_hours(bot, user_data, db, max_api)
return
emotion = await detect_emotion(text)
if emotion == "negative":
tmpl = await get_template_rendered(db, "emotion_escalate")
await max_api.send_message(max_user_id, tmpl)
from app.handlers.handoff import handle_handoff_request
await handle_handoff_request(bot, user_data, db, max_api)
from app.max_notifications import notify_handoff_escalation
user = await get_or_create_user(db, max_user_id)
conv = None
try:
from app.conversation import get_open_conversation
conv = await get_open_conversation(db, user.id)
except Exception:
pass
await notify_handoff_escalation(
db, max_api, user,
conv.id if conv else 0, text
)
return
kb_entry = await find_in_knowledge_base(db, text)
if kb_entry:
await max_api.send_message(max_user_id, kb_entry.answer, format="markdown")
return
from app.conversation import get_or_create_user
user = await get_or_create_user(db, max_user_id)
if not user.consent_given:
from app.handlers.greeting import handle_start
await handle_start(bot, user_data, db, max_api)
return
from app.handlers.main_menu import handle_main_menu
user_data["payload"] = "new_inquiry"
await handle_main_menu(bot, user_data, db, max_api)
return
if state == BotState.NAME_REQUEST:
if not text:
await max_api.send_message(max_user_id, "Как к вам обращаться?")
return
user = await get_or_create_user(db, max_user_id)
user.first_name = text
await db.flush()
from app.handlers.consent import handle_consent_request
await handle_consent_request(bot, user_data, db, max_api)
return
if state == BotState.CONSENT_REQUEST:
if text.lower() in ("да", "даю согласие", "согласен", "yes"):
from app.handlers.consent import handle_consent_given
await handle_consent_given(bot, user_data, db, max_api)
elif text.lower() in ("нет", "не даю", "не согласен", "no"):
from app.handlers.consent import handle_consent_refused
await handle_consent_refused(bot, user_data, db, max_api)
else:
from app.handlers.main_menu import handle_main_menu
user_data["payload"] = text.lower()
await handle_main_menu(bot, user_data, db, max_api)
return
if state == BotState.CONTACT_REQUEST:
from app.handlers.contact import handle_contact_text
await handle_contact_text(bot, user_data, db, max_api)
return
if state == BotState.INQUIRY_REQUEST:
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(bot, user_data, db, max_api)
return
if state == BotState.CATEGORY_SELECT:
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(bot, user_data, db, max_api)
return
if state == BotState.OUT_OF_HOURS:
from app.handlers.out_of_hours import handle_out_of_hours_callback
await handle_out_of_hours_callback(bot, user_data, db, max_api, text.lower())
return
_feature_handlers = {
BotState.FEATURE_RISK_SCORE: ("app.handlers.features.risk_score", "handle_risk_score"),
BotState.FEATURE_SLA_STATUS: ("app.handlers.features.sla_status", "handle_sla_status"),
BotState.FEATURE_PHOTO_REPORT: ("app.handlers.features.photo_report", "handle_photo_report"),
BotState.FEATURE_APPOINTMENT: ("app.handlers.features.appointment", "handle_appointment"),
BotState.FEATURE_COMMERCIAL: ("app.handlers.features.commercial", "handle_commercial"),
BotState.FEATURE_MY_OBJECTS: ("app.handlers.features.my_objects", "handle_my_objects"),
BotState.FEATURE_REMINDERS: ("app.handlers.features.reminders", "handle_reminders"),
BotState.FEATURE_QUALITY_RATE: ("app.handlers.features.quality_rate", "handle_quality_rate"),
BotState.FEATURE_NOTIFICATIONS: ("app.handlers.features.notifications", "handle_notifications"),
BotState.FEATURE_BROADCAST: ("app.handlers.features.broadcasts", "handle_broadcast_send"),
BotState.SLA_TICKET_CREATE: ("app.handlers.features.sla_ticket", "handle_sla_ticket"),
}
if state in _feature_handlers:
mod_path, func_name = _feature_handlers[state]
import importlib
mod = importlib.import_module(mod_path)
func = getattr(mod, func_name)
await func(bot, user_data, db, max_api)
return
if state == BotState.KB_SEARCH:
kb_entry = await find_in_knowledge_base(db, text)
if kb_entry:
await max_api.send_message(max_user_id, kb_entry.answer, format="markdown")
FSM.transition(max_user_id, "found")
else:
await max_api.send_message(max_user_id,
"К сожалению, не нашёл ответа на ваш вопрос. "
"Опишите подробнее или нажмите «Оставить заявку» для создания обращения.")
FSM.transition(max_user_id, "not_found")
from app.keyboards.inline import build_main_menu_buttons
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
from app.handlers.main_menu import handle_main_menu
user_data["payload"] = text.lower()
await handle_main_menu(bot, user_data, db, max_api)
async def _handle_callback(bot, user_data, update, db, max_api):
max_user_id = user_data["user_id"]
callback = update.get("callback", {})
payload = callback.get("payload", "")
callback_id = callback.get("id", "")
user_data["payload"] = payload
if payload == "cancel":
FSM.reset(max_user_id)
from app.keyboards.inline import build_main_menu_buttons
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
await max_api.answer_callback(callback_id)
return
if payload.startswith("rate_"):
from app.handlers.features.quality_rate import handle_quality_rate
await handle_quality_rate(bot, user_data, db, max_api)
await max_api.answer_callback(callback_id)
return
from app.handlers.main_menu import handle_main_menu
await handle_main_menu(bot, user_data, db, max_api)
await max_api.answer_callback(callback_id)
async def _handle_contact_shared(bot, user_data, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
if user_data.get("phone"):
user.phone = user_data["phone"]
if user_data.get("first_name"):
user.first_name = user_data["first_name"]
await db.flush()
state = FSM.get_state(max_user_id)
if state == BotState.CONTACT_REQUEST:
from app.handlers.contact import handle_contact_provided
await handle_contact_provided(bot, user_data, db, max_api)
else:
from app.handlers.main_menu import handle_main_menu
user_data["payload"] = "new_inquiry"
await handle_main_menu(bot, user_data, db, max_api)
+15 -17
View File
@@ -1,24 +1,22 @@
import os from typing import List, Optional
from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone" database_url: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone"
APP_ENV: str = "development" max_token: str = "f9LHodD0cOKkk03DYXj906MdIeRs3HdDo0BC5H2fLMVCMz3PA_Vx4aJqt2l2qPLM5zd5EQ1Zh6APxzpw2UYE"
LOG_LEVEL: str = "debug" max_api_base: str = "https://platform-api.max.ru"
WEBHOOK_PATH: str = "/webhook" webhook_url: str = "https://max.aegisone.ru/webhook"
WEBHOOK_SECRET: str = "change-me-to-random-secret" webhook_secret: str = "aegisone_bot_secret_2026"
UPLOADS_DIR: str = str(Path(__file__).parent.parent / "uploads" / "bot") config_php_path: str = "/var/www/html/config.php"
YANDEX_DISK_TOKEN: str = "" php_sendmail_path: str = "/usr/sbin/sendmail"
YANDEX_DISK_ROOT: str = "/AegisOne_Service" default_assistant_name: str = "София"
default_assistant_role: str = "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности"
default_support_email: str = "zakaz@aegisone.ru"
default_email_subject: str = "Обращение через Виртуального помощника AegisOne Engineering"
update_types: List[str] = ["bot_started", "message_created", "message_callback"]
class Config: model_config = {"env_prefix": "BOT_", "env_file": ".env"}
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache() settings = Settings()
def get_settings() -> Settings:
return Settings()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import re
import os
from typing import Optional, List, Tuple
from app.config import settings
class ConfigReader:
def __init__(self):
self._cache = {}
self._loaded = False
def load(self, path: Optional[str] = None) -> dict:
filepath = path or settings.config_php_path
if not os.path.isfile(filepath):
return self._fallback()
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
constants = {}
pattern = re.compile(r"define\(\s*'(\w+)'\s*,\s*'([^']*)'\s*\)")
for match in pattern.finditer(content):
constants[match.group(1)] = match.group(2)
self._cache = constants
self._loaded = True
return constants
def _fallback(self) -> dict:
self._cache = {
"PHONE_MAIN": "+7 (861) 203-33-30",
"PHONE_MAIN_LINK": "+78612033330",
"PHONE_SECOND": "+7 (995) 203-33-30",
"PHONE_SECOND_LINK": "+79952033330",
"SITE_MAIL": "mail@aegisone.ru",
"SITE_MAIL_FROM": "no-reply@aegisone.ru",
"SITE_MAIL_TO": "mail@aegisone.ru",
}
self._loaded = True
return self._cache
def get(self, key: str, default: str = "") -> str:
if not self._loaded:
self.load()
return self._cache.get(key, default)
def get_phones(self) -> List[Tuple[str, str]]:
return [
(self.get("PHONE_MAIN"), self.get("PHONE_MAIN_LINK")),
(self.get("PHONE_SECOND"), self.get("PHONE_SECOND_LINK")),
]
def get_site_mail(self) -> str:
return self.get("SITE_MAIL", "mail@aegisone.ru")
def get_mail_from(self) -> str:
return self.get("SITE_MAIL_FROM", "no-reply@aegisone.ru")
config_reader = ConfigReader()
-142
View File
@@ -1,142 +0,0 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import (
BotUser, BotConversation, BotMessage, BotConsentLog,
BotCategory, BotSetting, BotFeature, BotResponseTemplate, BotKnowledgeBase
)
from app.settings_cache import SettingsCache
from app.text_renderer import render_template
async def get_or_create_user(db: AsyncSession, max_user_id: int,
first_name: str = "", last_name: str = "",
username: str = "") -> BotUser:
result = await db.execute(select(BotUser).where(BotUser.max_user_id == max_user_id))
user = result.scalar_one_or_none()
if user:
user.last_active_at = datetime.now()
if first_name and not user.first_name:
user.first_name = first_name
if last_name and not user.last_name:
user.last_name = last_name
if username and not user.username:
user.username = username
return user
user = BotUser(
max_user_id=max_user_id, first_name=first_name,
last_name=last_name, username=username
)
db.add(user)
await db.flush()
return user
async def get_open_conversation(db: AsyncSession, user_id: int) -> Optional[BotConversation]:
result = await db.execute(
select(BotConversation).where(
BotConversation.user_id == user_id,
BotConversation.status == "open"
).order_by(BotConversation.created_at.desc()).limit(1)
)
return result.scalar_one_or_none()
async def create_conversation(db: AsyncSession, user_id: int) -> BotConversation:
conv = BotConversation(user_id=user_id, status="open")
db.add(conv)
await db.flush()
return conv
async def add_message(db: AsyncSession, conversation_id: int, direction: str,
text: str = "", has_attachment: bool = False,
attachment_type: str = "", attachment_path: str = "",
max_message_id: int = None) -> BotMessage:
msg = BotMessage(
conversation_id=conversation_id, direction=direction, text=text,
has_attachment=has_attachment, attachment_type=attachment_type,
attachment_path=attachment_path, max_message_id=max_message_id
)
db.add(msg)
await db.flush()
return msg
async def log_consent(db: AsyncSession, user_id: int, action: str, method: str = "", ip: str = ""):
log = BotConsentLog(user_id=user_id, action=action, method=method, ip_address=ip)
db.add(log)
await db.flush()
async def get_template(db: AsyncSession, key: str) -> str:
await SettingsCache.refresh(db)
return SettingsCache.get_template(key)
async def get_template_rendered(db: AsyncSession, key: str, **kwargs) -> str:
tmpl = await get_template(db, key)
return render_template(tmpl, **kwargs)
async def find_in_knowledge_base(db: AsyncSession, query: str) -> Optional[BotKnowledgeBase]:
query_lower = query.lower()
result = await db.execute(
select(BotKnowledgeBase).where(BotKnowledgeBase.active == True)
)
entries = result.scalars().all()
for entry in entries:
if query_lower in entry.question.lower():
return entry
keywords = entry.keywords or []
for kw in keywords:
if kw.lower() in query_lower:
return entry
return None
async def auto_categorize_text(db: AsyncSession, text: str) -> Optional[BotCategory]:
text_lower = text.lower()
categories = SettingsCache.get_categories()
best_match = None
best_score = 0
for cat in categories:
keywords = cat.keywords or []
score = sum(1 for kw in keywords if kw.lower() in text_lower)
if score > best_score:
best_score = score
best_match = cat
return best_match if best_score > 0 else None
async def close_conversation(db: AsyncSession, conversation_id: int):
conv = await db.execute(
select(BotConversation).where(BotConversation.id == conversation_id)
)
conv = conv.scalar_one_or_none()
if conv:
conv.status = "closed"
conv.closed_at = datetime.now()
await db.flush()
async def update_conversation_status(db: AsyncSession, conversation_id: int, status: str):
conv = await db.execute(
select(BotConversation).where(BotConversation.id == conversation_id)
)
conv = conv.scalar_one_or_none()
if conv:
conv.status = status
await db.flush()
async def detect_negative_emotion(text: str) -> bool:
negative_words = [
"ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо",
"бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь",
"раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд",
"верните деньги", "мошенники", "обман"
]
text_lower = text.lower()
return any(word in text_lower for word in negative_words)
+6 -15
View File
@@ -1,22 +1,13 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import get_settings from app.config import 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)
engine = create_async_engine(settings.database_url, pool_size=10, max_overflow=20)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db(): async def get_db() -> AsyncSession:
async with async_session() as session: async with async_session() as session:
try: try:
yield session yield session
await session.commit() finally:
except Exception: await session.close()
await session.rollback()
raise
+80
View File
@@ -0,0 +1,80 @@
import subprocess
import os
from typing import Optional
from app.config import settings
from app.config_reader import config_reader
class EmailSender:
def __init__(self):
self.sendmail_path = settings.php_sendmail_path
def send(
self,
to: str,
subject: str,
body: str,
from_addr: Optional[str] = None,
) -> bool:
if from_addr is None:
from_addr = config_reader.get_mail_from()
message = self._build_message(to, subject, body, from_addr)
try:
if os.name == "nt":
return self._send_debug(to, subject, body, from_addr)
proc = subprocess.run(
[self.sendmail_path, "-t", "-i"],
input=message,
capture_output=True,
text=True,
timeout=30,
)
return proc.returncode == 0
except Exception:
return self._send_debug(to, subject, body, from_addr)
def _send_debug(self, to: str, subject: str, body: str, from_addr: str) -> bool:
log_dir = os.path.join(os.path.dirname(__file__), "..", "logs")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "emails.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"=== EMAIL ===\nFrom: {from_addr}\nTo: {to}\nSubject: {subject}\n\n{body}\n\n")
return True
def _build_message(self, to: str, subject: str, body: str, from_addr: str) -> str:
lines = [
f"From: {from_addr}",
f"To: {to}",
f"Subject: {subject}",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
body,
]
return "\r\n".join(lines)
def send_ticket_escalation(
self,
to: str,
subject: str,
user_info: str,
contact: str,
inquiry: str,
history: str,
) -> bool:
body = (
f"Поступило новое обращение через Виртуального помощника AegisOne Engineering\n\n"
f"--- Данные клиента ---\n{user_info}\n"
f"--- Контактные данные ---\n{contact}\n"
f"--- Суть обращения ---\n{inquiry}\n"
f"--- История переписки ---\n{history}\n"
f"---\n"
f"Дата и время: {__import__('datetime').datetime.now().strftime('%d.%m.%Y %H:%M')}"
)
return self.send(to, subject, body)
email_sender = EmailSender()
-77
View File
@@ -1,77 +0,0 @@
import uuid
import json
import logging
from pathlib import Path
from datetime import datetime
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".txt", ".jpg", ".jpeg", ".png", ".gif"}
logger = logging.getLogger(__name__)
class FileStorage:
def __init__(self, base_dir: str = "uploads/bot"):
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
async def save_file(self, file_bytes: bytes, filename: str, conversation_id: int,
file_type: str = "document", db=None) -> str:
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
ext = ".bin"
safe_name = f"{uuid.uuid4().hex}{ext}"
conv_dir = self.base_dir / str(conversation_id)
conv_dir.mkdir(parents=True, exist_ok=True)
file_path = conv_dir / safe_name
file_path.write_bytes(file_bytes)
meta = {
"type": "conversation",
"id": conversation_id,
"filename": filename,
"file_type": file_type,
"timestamp": datetime.now().isoformat(),
"local_path": str(file_path),
}
meta_path = conv_dir / "metadata.json"
if meta_path.exists():
try:
existing = json.loads(meta_path.read_text())
if "attachments" not in existing:
existing["attachments"] = []
existing["attachments"].append({"filename": safe_name, "original": filename})
meta_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
except Exception:
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
else:
meta["attachments"] = [{"filename": safe_name, "original": filename}]
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
# Queue for Yandex Disk sync (service portal picks this up)
if db is not None:
try:
from app.models.models import FileSyncQueue
remote_path = f"bot/conversations/{conversation_id}/{safe_name}"
queue = FileSyncQueue(
local_path=str(file_path),
remote_path=remote_path,
entity_type="conversation",
entity_id=conversation_id,
status="pending",
)
db.add(queue)
await db.flush()
except Exception as e:
logger.error(f"Failed to queue file sync: {e}")
return str(file_path)
def get_file_type(self, filename: str) -> str:
ext = Path(filename).suffix.lower()
if ext in {".jpg", ".jpeg", ".png", ".gif"}:
return "image"
if ext in {".pdf", ".docx", ".xlsx", ".txt"}:
return "document"
return "other"
storage = FileStorage()
-209
View File
@@ -1,209 +0,0 @@
from enum import Enum
class BotState(str, Enum):
IDLE = "idle"
NAME_REQUEST = "name_request"
CONSENT_REQUEST = "consent_request"
CONTACT_REQUEST = "contact_request"
INQUIRY_REQUEST = "inquiry_request"
CATEGORY_SELECT = "category_select"
CONFIRMATION = "confirmation"
HANDOFF_REQUEST = "handoff_request"
FEATURE_RISK_SCORE = "feature_risk_score"
FEATURE_SLA_STATUS = "feature_sla_status"
FEATURE_PHOTO_REPORT = "feature_photo_report"
FEATURE_APPOINTMENT = "feature_appointment"
FEATURE_QUALITY_RATE = "feature_quality_rate"
FEATURE_COMMERCIAL = "feature_commercial"
FEATURE_MY_OBJECTS = "feature_my_objects"
FEATURE_REMINDERS = "feature_reminders"
SLA_TICKET_CREATE = "sla_ticket_create"
KB_SEARCH = "kb_search"
FEATURE_NOTIFICATIONS = "feature_notifications"
FEATURE_BROADCAST = "feature_broadcast"
AWAITING_RATING = "awaiting_rating"
WAITING_1C_RESPONSE = "waiting_1c_response"
MAIN_MENU = "main_menu"
HANDOFF_CONFIRMATION = "handoff_confirmation"
OUT_OF_HOURS = "out_of_hours"
STATE_TRANSITIONS = {
BotState.IDLE: {
"start": BotState.NAME_REQUEST,
"new_inquiry": BotState.NAME_REQUEST,
"handoff": BotState.HANDOFF_REQUEST,
"main_menu": BotState.MAIN_MENU,
"cancel": BotState.IDLE,
},
BotState.NAME_REQUEST: {
"name_provided": BotState.CONSENT_REQUEST,
"cancel": BotState.IDLE,
},
BotState.CONSENT_REQUEST: {
"consent_given": BotState.CONTACT_REQUEST,
"consent_refused": BotState.IDLE,
"cancel": BotState.IDLE,
},
BotState.CONTACT_REQUEST: {
"contact_provided": BotState.INQUIRY_REQUEST,
"cancel": BotState.IDLE,
},
BotState.INQUIRY_REQUEST: {
"inquiry_provided": BotState.CATEGORY_SELECT,
"cancel": BotState.IDLE,
},
BotState.CATEGORY_SELECT: {
"category_selected": BotState.CONFIRMATION,
"cancel": BotState.IDLE,
},
BotState.CONFIRMATION: {
"done": BotState.IDLE,
"edit_name": BotState.NAME_REQUEST,
"edit_contact": BotState.CONTACT_REQUEST,
"edit_inquiry": BotState.INQUIRY_REQUEST,
},
BotState.HANDOFF_REQUEST: {
"confirmed": BotState.HANDOFF_CONFIRMATION,
"cancel": BotState.MAIN_MENU,
},
BotState.HANDOFF_CONFIRMATION: {
"done": BotState.IDLE,
"cancel": BotState.MAIN_MENU,
},
BotState.OUT_OF_HOURS: {
"leave_message": BotState.INQUIRY_REQUEST,
"callback": BotState.NAME_REQUEST,
"cancel": BotState.IDLE,
"main_menu": BotState.MAIN_MENU,
},
BotState.MAIN_MENU: {
"new_inquiry": BotState.NAME_REQUEST,
"feature_risk_score": BotState.FEATURE_RISK_SCORE,
"feature_sla_status": BotState.FEATURE_SLA_STATUS,
"feature_photo_report": BotState.FEATURE_PHOTO_REPORT,
"feature_appointment": BotState.FEATURE_APPOINTMENT,
"feature_quality_rate": BotState.FEATURE_QUALITY_RATE,
"feature_commercial": BotState.FEATURE_COMMERCIAL,
"feature_my_objects": BotState.FEATURE_MY_OBJECTS,
"feature_reminders": BotState.FEATURE_REMINDERS,
"feature_notifications": BotState.FEATURE_NOTIFICATIONS,
"feature_broadcast": BotState.FEATURE_BROADCAST,
"sla_ticket_create": BotState.SLA_TICKET_CREATE,
"handoff": BotState.HANDOFF_REQUEST,
"kb_search": BotState.KB_SEARCH,
"cancel": BotState.IDLE,
},
BotState.FEATURE_RISK_SCORE: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_SLA_STATUS: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_PHOTO_REPORT: {
"done": BotState.MAIN_MENU,
"photo_provided": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_APPOINTMENT: {
"done": BotState.MAIN_MENU,
"date_provided": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_QUALITY_RATE: {
"done": BotState.MAIN_MENU,
"rating_provided": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_COMMERCIAL: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_MY_OBJECTS: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_REMINDERS: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_NOTIFICATIONS: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.FEATURE_BROADCAST: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.SLA_TICKET_CREATE: {
"done": BotState.MAIN_MENU,
"cancel": BotState.MAIN_MENU,
},
BotState.KB_SEARCH: {
"found": BotState.MAIN_MENU,
"not_found": BotState.INQUIRY_REQUEST,
"cancel": BotState.MAIN_MENU,
},
BotState.AWAITING_RATING: {
"rating_provided": BotState.IDLE,
"skip": BotState.IDLE,
},
BotState.WAITING_1C_RESPONSE: {
"received": BotState.MAIN_MENU,
"timeout": BotState.IDLE,
"cancel": BotState.MAIN_MENU,
},
}
class FSM:
_states: dict = {}
_tmp: dict = {}
@classmethod
def get_state(cls, user_id: int) -> BotState:
return cls._states.get(user_id, BotState.IDLE)
@classmethod
def set_state(cls, user_id: int, state: BotState):
cls._states[user_id] = state
@classmethod
def reset(cls, user_id: int):
cls._states.pop(user_id, None)
cls._tmp.pop(user_id, None)
@classmethod
def set_temp(cls, user_id: int, data: dict):
cls._tmp[user_id] = data
@classmethod
def get_temp(cls, user_id: int) -> dict:
return cls._tmp.get(user_id, {})
@classmethod
def clear_temp(cls, user_id: int):
cls._tmp.pop(user_id, None)
@classmethod
def can_transition(cls, user_id: int, event: str) -> bool:
current = cls.get_state(user_id)
return event in STATE_TRANSITIONS.get(current, {})
@classmethod
def transition(cls, user_id: int, event: str) -> BotState:
current = cls.get_state(user_id)
transitions = STATE_TRANSITIONS.get(current, {})
if event not in transitions:
return current
new_state = transitions[event]
cls.set_state(user_id, new_state)
return new_state
@classmethod
def clear_all(cls):
cls._states.clear()
cls._tmp.clear()
+95 -48
View File
@@ -1,60 +1,107 @@
from app.settings_cache import SettingsCache import datetime
from app.conversation import get_or_create_user, log_consent, get_template_rendered import logging
from app.keyboards.inline import build_consent_buttons, build_contact_buttons from sqlalchemy import select
from app.fsm import BotState, FSM from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage
from app.max_api import max_api
from app.config_reader import config_reader
from app.keyboards import contact_keyboard, consent_keyboard, return_to_consent_keyboard
logger = logging.getLogger(__name__)
async def handle_consent_request(bot, user_data: dict, db, max_api): async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
max_user_id = user_data["user_id"] if callback_id == "consent_yes":
user = await get_or_create_user(db, max_user_id, await handle_consent_yes(user_id, conv_id)
user_data.get("first_name", ""), elif callback_id == "consent_no":
user_data.get("last_name", ""), await handle_consent_no(user_id, conv_id)
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): async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
max_user_id = user_data["user_id"] text_lower = text.strip().lower()
user = await get_or_create_user(db, max_user_id) consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
if any(w in text_lower for w in consent_words):
await handle_consent_yes(user_id, conv_id)
elif any(w in text_lower for w in refuse_words):
await handle_consent_no(user_id, conv_id)
else:
await max_api.send_message(
user_id,
"Пожалуйста, дайте чёткий ответ — даёте ли вы согласие на обработку "
"персональных данных? Это необходимо для обработки вашего запроса.",
attachments=consent_keyboard(),
)
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
if user:
user.consent_given = True user.consent_given = True
from datetime import datetime user.consent_date = datetime.datetime.utcnow()
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") result = await db.execute(
buttons = build_contact_buttons() select(BotConversation).where(BotConversation.id == conv_id)
await max_api.send_message_with_keyboard(max_user_id, text, buttons) )
FSM.set_state(max_user_id, BotState.CONTACT_REQUEST) conv = result.scalar_one_or_none()
if conv:
conv.state = "awaiting_contact"
await db.commit()
await max_api.send_message(
user_id,
"Спасибо! Поделитесь, пожалуйста, вашим контактом, чтобы мы могли "
"связаться с вами.\n\n"
"Нажмите кнопку «Поделиться контактом» или введите номер телефона / email вручную.",
attachments=contact_keyboard(),
)
async def handle_consent_refused(bot, user_data: dict, db, max_api): async def handle_consent_no(user_id: int, conv_id: int) -> None:
max_user_id = user_data["user_id"] async with async_session() as db:
user = await get_or_create_user(db, max_user_id) result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "consent_refused"
await db.commit()
await log_consent(db, user.id, "revoked", "button", user_data.get("ip", "")) phones = config_reader.get_phones()
await db.flush() email = config_reader.get_site_mail()
await SettingsCache.refresh(db) phone_text = "\n".join(
phone_1 = SettingsCache.get("phone_1") f"📞 {phone}" for phone, _ in phones
phone_2 = SettingsCache.get("phone_2") )
email = SettingsCache.get("support_email")
text = await get_template_rendered(db, "consent_refused", await max_api.send_message(
phone_1=phone_1, phone_2=phone_2, user_id,
support_email=email) f"Без вашего согласия мы не можем обработать обращение.\n\n"
from app.keyboards.inline import build_phone_email_buttons f"Вы можете позвонить нам:\n{phone_text}\n\n"
subject = SettingsCache.get("email_subject") f"Или написать на почту: {email}\n\n"
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject) f"Если передумаете и захотите дать согласие — просто напишите об этом.",
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") )
FSM.reset(max_user_id)
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
if any(w in text_lower for w in consent_words):
await handle_consent_yes(user_id, conv_id)
else:
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = ", ".join(phone for phone, _ in phones)
await max_api.send_message(
user_id,
f"Связаться с нами можно только по телефону {phone_text} "
f"или по почте {email}. "
f"Если хотите дать согласие на обработку данных — напишите «Даю согласие».",
attachments=return_to_consent_keyboard(),
)
+66 -32
View File
@@ -1,43 +1,77 @@
import datetime
import re import re
from app.settings_cache import SettingsCache import logging
from app.conversation import get_or_create_user, get_template_rendered from sqlalchemy import select
from app.fsm import BotState, FSM from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage
from app.max_api import max_api
logger = logging.getLogger(__name__)
PHONE_PATTERN = re.compile(r"^(\+7|8|7)?[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}$")
EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
async def handle_contact_provided(bot, user_data: dict, db, max_api): async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
max_user_id = user_data["user_id"] async with async_session() as db:
user = await get_or_create_user(db, max_user_id) result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
if user:
if PHONE_PATTERN.match(contact_text):
user.phone = contact_text
elif EMAIL_PATTERN.match(contact_text):
user.phone = contact_text
phone = user_data.get("phone", "") result = await db.execute(
email = user_data.get("email", "") select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "awaiting_inquiry"
if conv.intent == "unknown" or not conv.intent:
await _ask_inquiry(user_id, conv)
else:
await _ask_inquiry_with_context(user_id, conv)
if phone: await db.commit()
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): async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
max_user_id = user_data["user_id"] text = text.strip()
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) async with async_session() as db:
email_match = re.search(r'([\w.-]+@[\w.-]+\.\w+)', text) msg = BotMessage(
conversation_id=conv_id,
direction="incoming",
text=text,
created_at=datetime.datetime.utcnow(),
)
db.add(msg)
await db.commit()
if phone_match: if PHONE_PATTERN.match(text) or EMAIL_PATTERN.match(text):
user.phone = phone_match.group(1) await handle_contact_received(user_id, conv_id, text)
if email_match: else:
user.email = email_match.group(1) await max_api.send_message(
user_id,
"Пожалуйста, введите корректный номер телефона (например, +7 (861) 203-33-30) "
"или адрес электронной почты.",
)
if phone_match or email_match:
await db.flush()
await handle_contact_provided(bot, user_data, db, max_api)
return
await max_api.send_message(max_user_id, "Пожалуйста, укажите номер телефона или email, чтобы мы могли с вами связаться.") async def _ask_inquiry(user_id: int, conv: BotConversation) -> None:
await max_api.send_message(
user_id,
"Опишите, пожалуйста, ваш вопрос или задачу. "
"Расскажите подробнее, чем мы можем вам помочь.",
)
async def _ask_inquiry_with_context(user_id: int, conv: BotConversation) -> None:
intent_label = "вопрос" if conv.intent == "question" else "заявку на услугу" if conv.intent == "ticket" else "обращение"
await max_api.send_message(
user_id,
f"Ранее вы упомянули, что хотите оставить {intent_label}. "
"Опишите подробнее суть вашего обращения, чтобы я могла передать "
"информацию специалисту.",
)
@@ -1,105 +0,0 @@
from datetime import datetime
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_DATE_KEY = "appt_date"
_SLOT_KEY = "appt_slot"
async def handle_appointment(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
payload = user_data.get("payload", "")
state = FSM.get_state(max_user_id)
if state != BotState.FEATURE_APPOINTMENT:
FSM.set_state(max_user_id, BotState.FEATURE_APPOINTMENT)
FSM.set_temp(max_user_id, {})
await max_api.send_message(
max_user_id,
"📅 **Запись на визит**\n\nУкажите желаемую дату (ДД.ММ.ГГГГ):"
)
return
tmp = FSM.get_temp(max_user_id)
if _DATE_KEY not in tmp:
try:
parsed = datetime.strptime(text.strip(), "%d.%m.%Y")
tmp[_DATE_KEY] = parsed.strftime("%Y-%m-%d")
FSM.set_temp(max_user_id, tmp)
slots = _generate_slots()
msg_lines = [f"📅 **{parsed.strftime('%d.%m.%Y')}**\n\nДоступные слоты:\n"]
for i, slot in enumerate(slots, 1):
msg_lines.append(f"{i}. {slot}")
msg_lines.append("\nВведите номер слота (1-{}):".format(len(slots)))
await max_api.send_message(max_user_id, "\n".join(msg_lines), format="markdown")
except Exception:
await max_api.send_message(max_user_id, "Неверный формат даты. Укажите дату в формате ДД.ММ.ГГГГ")
return
if _SLOT_KEY not in tmp:
slots = _generate_slots()
try:
idx = int(text.strip()) - 1
if 0 <= idx < len(slots):
tmp[_SLOT_KEY] = slots[idx]
FSM.set_temp(max_user_id, tmp)
await _confirm_appointment(bot, user_data, db, max_api, tmp)
return
except (ValueError, IndexError):
pass
await max_api.send_message(max_user_id, f"Укажите номер слота (1-{len(slots)}):")
return
await _confirm_appointment(bot, user_data, db, max_api, tmp)
def _generate_slots() -> list:
start_h = SettingsCache.get_int("work_hours_start", 9)
end_h = SettingsCache.get_int("work_hours_end", 18)
slots = []
for h in range(start_h, end_h):
slots.append(f"{h:02d}:00-{h + 1:02d}:00")
return slots
async def _confirm_appointment(bot, user_data, db, max_api, tmp):
max_user_id = user_data["user_id"]
date_str = tmp.get(_DATE_KEY, "")
slot_str = tmp.get(_SLOT_KEY, "")
user = await get_or_create_user(db, max_user_id)
text = await get_template_rendered(db, "appointment_confirm",
date=f"{date_str} {slot_str}",
engineer="будет назначен")
await max_api.send_message(max_user_id, text, format="markdown")
conv = await _create_appointment_conv(db, user.id, f"{date_str} {slot_str}")
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _create_appointment_conv(db, user_id, details):
from app.conversation import create_conversation, add_message
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(
select(BotCategory).where(BotCategory.name == "Обслуживание").limit(1)
)
cat = result.scalar_one_or_none()
conv = await create_conversation(db, user_id)
conv.inquiry_text = f"Запись на визит: {details}"
conv.inquiry_type = cat.id if cat else None
conv.priority = "normal"
await db.flush()
await add_message(db, conv.id, "in", details)
return conv
@@ -1,44 +0,0 @@
from datetime import datetime
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
async def handle_broadcast_send(bot, user_data: dict, db, max_api):
from app.models.models import BotUser
from sqlalchemy import select
text = user_data.get("text", "").strip()
if not text:
await max_api.send_message(
user_data["user_id"],
"Укажите текст рассылки в параметре broadcast_text."
)
return
result = await db.execute(select(BotUser).where(BotUser.consent_given == True))
users = result.scalars().all()
sent = 0
failed = 0
sent_ids = []
for u in users:
try:
await max_api.send_message(user_id=u.max_user_id, text=text, format="markdown")
sent += 1
sent_ids.append(u.max_user_id)
except Exception:
failed += 1
from app.models.models import BotBroadcast
bc = BotBroadcast(
text=text,
sent_at=datetime.now(),
recipient_count=len(users),
recipient_ids=sent_ids,
status="sent",
)
db.add(bc)
await db.flush()
result = f"📨 Рассылка завершена.\nОтправлено: {sent}\nОшибок: {failed}"
await max_api.send_message(user_data["user_id"], result)
-101
View File
@@ -1,101 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_STEPS = ["service_type", "scope", "address", "comment"]
_STEP_LABELS = {
"service_type": "Тип услуги (Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, Обслуживание, Другое)",
"scope": "Объём работ (краткое описание)",
"address": "Адрес объекта",
"comment": "Дополнительные пожелания (или отправьте «-» для пропуска)",
}
_COLLECT_KEY = "commercial_data"
_STEP_KEY = "commercial_step"
async def handle_commercial(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
state = FSM.get_state(max_user_id)
if state != BotState.FEATURE_COMMERCIAL:
FSM.set_state(max_user_id, BotState.FEATURE_COMMERCIAL)
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _COLLECT_KEY: {}})
await max_api.send_message(
max_user_id,
f"📄 **Коммерческое предложение**\n\nШаг 1/{len(_STEPS)}:\n{_STEP_LABELS['service_type']}"
)
return
tmp = FSM.get_temp(max_user_id)
step = tmp.get(_STEP_KEY, 0)
data = tmp.get(_COLLECT_KEY, {})
if step >= len(_STEPS):
await _finish_commercial(bot, user_data, db, max_api, data)
return
step_key = _STEPS[step]
if not text and step_key != "comment":
await max_api.send_message(max_user_id, f"Пожалуйста, заполните поле:\n{_STEP_LABELS[step_key]}")
return
data[step_key] = text if text else ""
step += 1
if step < len(_STEPS):
FSM.set_temp(max_user_id, {_STEP_KEY: step, _COLLECT_KEY: data})
next_key = _STEPS[step]
await max_api.send_message(
max_user_id,
f"📄 Шаг {step + 1}/{len(_STEPS)}:\n{_STEP_LABELS[next_key]}"
)
else:
await _finish_commercial(bot, user_data, db, max_api, data)
async def _finish_commercial(bot, user_data, db, max_api, data):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
summary = (
f"📄 **Коммерческое предложение**\n\n"
f"Услуга: {data.get('service_type', '')}\n"
f"Объём: {data.get('scope', '')}\n"
f"Адрес: {data.get('address', '')}\n"
f"Комментарий: {data.get('comment', '')}\n\n"
f"Спасибо, {user.first_name}! Мы подготовим КП и свяжемся с вами."
)
await max_api.send_message(max_user_id, summary, format="markdown")
conv_text = (
f"Коммерческое предложение: услуга={data.get('service_type', '')}, "
f"объём={data.get('scope', '')}, адрес={data.get('address', '')}, "
f"комментарий={data.get('comment', '')}"
)
conv = await _create_commercial_conv(db, user.id, conv_text)
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _create_commercial_conv(db, user_id, details):
from app.conversation import create_conversation, add_message
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(
select(BotCategory).where(BotCategory.name == "Консультация").limit(1)
)
cat = result.scalar_one_or_none()
conv = await create_conversation(db, user_id)
conv.inquiry_text = details
conv.inquiry_type = cat.id if cat else None
await db.flush()
await add_message(db, conv.id, "in", details)
return conv
@@ -1,39 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_my_objects(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_MY_OBJECTS:
FSM.set_state(max_user_id, BotState.FEATURE_MY_OBJECTS)
user = await get_or_create_user(db, max_user_id)
await max_api.send_message(max_user_id,
"🏢 **Мои объекты**\n\n"
"Укажите название вашей компании для поиска объектов."
)
return
if not text:
await max_api.send_message(max_user_id, "Укажите название компании.")
return
from app.integrations.portal_db import get_objects_for_customer
objects = await get_objects_for_customer(db, text)
if not objects:
await max_api.send_message(max_user_id, "Объекты не найдены. Проверьте название компании.")
else:
lines = [f"🏢 **{obj.name}** — {obj.status}" for obj in objects[:10]]
msg = "Ваши объекты:\n\n" + "\n".join(lines)
if len(objects) > 10:
msg += f"\n\n...и ещё {len(objects) - 10} объектов"
await max_api.send_message(max_user_id, msg, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,68 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_notifications(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if FSM.get_state(max_user_id) != BotState.FEATURE_NOTIFICATIONS:
FSM.set_state(max_user_id, BotState.FEATURE_NOTIFICATIONS)
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select
from app.models.models import BotNotificationSubscription
result = await db.execute(
select(BotNotificationSubscription).where(
BotNotificationSubscription.user_id == user.id
).limit(1)
)
sub = result.scalar_one_or_none()
status_change = "" if sub and sub.notify_on_status_change else ""
reply = "" if sub and sub.notify_on_reply else ""
broadcast = "" if sub and sub.notify_on_broadcast else ""
text = (
"🔔 **Уведомления**\n\n"
"Выберите, о чём уведомлять:\n\n"
f"{status_change} /status — Смена статуса обращения\n"
f"{reply} /reply — Ответ оператора\n"
f"{broadcast} /broadcast — Новости и рассылки\n\n"
"Нажмите на пункт, чтобы переключить."
)
await max_api.send_message(max_user_id, text, format="markdown")
return
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select
from app.models.models import BotNotificationSubscription
result = await db.execute(
select(BotNotificationSubscription).where(
BotNotificationSubscription.user_id == user.id
).limit(1)
)
sub = result.scalar_one_or_none()
if not sub:
sub = BotNotificationSubscription(user_id=user.id)
db.add(sub)
await db.flush()
toggle_map = {
"/status": "notify_on_status_change",
"/reply": "notify_on_reply",
"/broadcast": "notify_on_broadcast",
}
key = payload if payload.startswith("/") else (payload if payload in toggle_map else user_data.get("text", "").strip())
if key in toggle_map:
col = toggle_map[key]
current = getattr(sub, col)
setattr(sub, col, not current)
await db.flush()
await max_api.send_message(max_user_id, f"Настройка «{key}» изменена.")
else:
await max_api.send_message(max_user_id, "Нажмите /status, /reply или /broadcast для переключения.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,69 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_open_conversation, create_conversation, add_message, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_photo_report(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_PHOTO_REPORT:
FSM.set_state(max_user_id, BotState.FEATURE_PHOTO_REPORT)
await max_api.send_message(max_user_id,
"📸 **Фото-отчёт**\n\n"
"Отправьте фото проблемы и опишите, что произошло.\n"
"Вы можете прикрепить документ (PDF, DOCX, XLSX, TXT)."
)
return
user = await get_or_create_user(db, max_user_id)
conv = await get_open_conversation(db, user.id)
if not conv:
conv = await create_conversation(db, user.id)
has_attachment = user_data.get("has_attachment", False)
attachment_type = user_data.get("attachment_type", "")
attachment_path = user_data.get("attachment_path", "")
conv.inquiry_text = text or "Фото-отчёт"
conv.has_attachment = has_attachment
conv.attachment_type = attachment_type
conv.attachment_path = attachment_path
conv.priority = "high"
await db.flush()
await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path)
ooh = not SettingsCache.is_work_hours()
if ooh:
start = SettingsCache.get_int("work_hours_start", 9)
end = SettingsCache.get_int("work_hours_end", 18)
ooh_text = await get_template_rendered(db, "out_of_hours",
work_hours_start=start, work_hours_end=end)
await max_api.send_message(max_user_id, ooh_text)
text = await get_template_rendered(db, "confirmation_ooh" if ooh else "confirmation",
first_name=user.first_name, conv_id=conv.id)
await max_api.send_message(max_user_id, text, format="markdown")
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
from app.models.models import Incident
incident = Incident(
object_id=0,
reported_by=None,
title=f"Фото-отчёт от {user.first_name or 'клиента'}",
description=text or "Фото-отчёт через бота",
severity="P3",
status="open",
)
db.add(incident)
await db.flush()
conv.attachment_path = f"incident_{incident.id}"
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,35 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
from app.keyboards.inline import build_quality_buttons, build_main_menu_buttons
from app.fsm import BotState, FSM
async def handle_quality_rate(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if FSM.get_state(max_user_id) != BotState.FEATURE_QUALITY_RATE:
FSM.set_state(max_user_id, BotState.FEATURE_QUALITY_RATE)
conv = await get_open_conversation(db, (await get_or_create_user(db, max_user_id)).id)
conv_id = conv.id if conv else ""
text = await get_template_rendered(db, "quality_request", conv_id=conv_id)
buttons = build_quality_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
return
if payload.startswith("rate_"):
try:
rating = int(payload.replace("rate_", ""))
except ValueError:
return
await max_api.send_message(max_user_id, f"Спасибо за оценку: {rating}/5! Ваш отзыв важен для нас.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,48 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_reminders(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_REMINDERS:
FSM.set_state(max_user_id, BotState.FEATURE_REMINDERS)
user = await get_or_create_user(db, max_user_id)
phone = user.phone or ""
from sqlalchemy import select, func
from app.models.models import BotConversation, BotTicket, Customer
open_convs = await db.execute(
select(func.count(BotConversation.id))
.where(BotConversation.status.in_(["open", "handoff"]))
)
total_open = open_convs.scalar() or 0
open_tickets = await db.execute(
select(func.count(BotTicket.id))
.where(BotTicket.status.in_(["open", "in_progress"]))
)
total_tickets = open_tickets.scalar() or 0
my_convs = await db.execute(
select(func.count(BotConversation.id))
.where(BotConversation.user_id == user.id, BotConversation.status.in_(["open", "handoff"]))
)
my_open = my_convs.scalar() or 0
text = (
"🔔 **Напоминания**\n\n"
f"У вас открыто обращений: {my_open}\n"
f"Всего в системе: {total_open} обращений, {total_tickets} заявок\n\n"
"Напоминания о плановом ТО настраиваются в портале."
)
await max_api.send_message(max_user_id, text, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
-100
View File
@@ -1,100 +0,0 @@
from sqlalchemy import select
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_ANSWERS_KEY = "risk_answers"
_STEP_KEY = "risk_step"
async def _load_questions(db):
from app.models.models import BotRiskQuestion
result = await db.execute(
select(BotRiskQuestion)
.where(BotRiskQuestion.is_active == True)
.order_by(BotRiskQuestion.sort_order)
)
return result.scalars().all()
async def handle_risk_score(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
state = FSM.get_state(max_user_id)
questions = await _load_questions(db)
if not questions:
await max_api.send_message(max_user_id, "Анкета риск-инжиниринга временно недоступна.")
FSM.transition(max_user_id, "cancel")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
if state != BotState.FEATURE_RISK_SCORE:
FSM.set_state(max_user_id, BotState.FEATURE_RISK_SCORE)
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _ANSWERS_KEY: {}})
q = questions[0]
await max_api.send_message(
max_user_id,
f"📊 **Risk Score — анкета**\n\nВопрос 1/{len(questions)}:\n{q.question}\n\nОтветьте «да» или «нет»."
)
return
tmp = FSM.get_temp(max_user_id)
step = tmp.get(_STEP_KEY, 0)
answers = tmp.get(_ANSWERS_KEY, {})
if step >= len(questions):
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
return
if text.lower() in ("да", "yes", "+", "1"):
answers[questions[step].question_key] = True
elif text.lower() in ("нет", "no", "-", "0"):
answers[questions[step].question_key] = False
else:
await max_api.send_message(max_user_id, "Пожалуйста, ответьте «да» или «нет».")
return
step += 1
if step < len(questions):
q = questions[step]
FSM.set_temp(max_user_id, {_STEP_KEY: step, _ANSWERS_KEY: answers})
await max_api.send_message(
max_user_id,
f"📊 Вопрос {step + 1}/{len(questions)}:\n{q.question}\n\n«да» или «нет»?"
)
else:
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
async def _finish_risk_score(max_user_id, answers, questions, db, max_api):
score = 0
for q in questions:
if answers.get(q.question_key):
score += q.weight
score = min(score, 100)
if score < 20:
label = "Низкий"
rec = "Поддерживайте текущий уровень обслуживания"
elif score < 50:
label = "Средний"
rec = "Рекомендуем провести аудит систем"
elif score < 75:
label = "Высокий"
rec = "Необходимо срочное обслуживание"
else:
label = "Критический"
rec = "Требуется немедленное вмешательство"
tmpl = await get_template_rendered(db, "risk_result", score=score, label=label, recommendation=rec)
await max_api.send_message(max_user_id, tmpl, format="markdown")
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,54 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_sla_status(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_SLA_STATUS:
FSM.set_state(max_user_id, BotState.FEATURE_SLA_STATUS)
await max_api.send_message(max_user_id,
"🔍 **Проверка SLA статуса**\n\n"
"Укажите номер или название объекта."
)
return
if not text:
await max_api.send_message(max_user_id, "Укажите номер или название объекта.")
return
from app.integrations.portal_db import get_object_by_name_or_id, get_sla_for_object, get_last_incident_for_object, get_active_tasks_for_object
obj = await get_object_by_name_or_id(db, text)
if not obj:
await max_api.send_message(max_user_id, "Объект не найден.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
sla = await get_sla_for_object(db, obj.id)
if not sla:
text = await get_template_rendered(db, "sla_not_found")
await max_api.send_message(max_user_id, text)
else:
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
last_incident = await get_last_incident_for_object(db, obj.id)
open_tasks = await get_active_tasks_for_object(db, obj.id)
last_visit = last_incident.created_at.strftime("%d.%m.%Y") if last_incident else ""
tasks_count = len(open_tasks)
text = await get_template_rendered(db, "sla_found",
object_name=obj.name,
service_level=level_map.get(sla.service_level, sla.service_level),
status=sla.status,
price=sla.sla_price_monthly or 0)
text += f"\n📅 Последний визит: {last_visit}\n📋 Открытых задач: {tasks_count}"
await max_api.send_message(max_user_id, text, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
-155
View File
@@ -1,155 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_sla_ticket(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.SLA_TICKET_CREATE:
FSM.set_state(max_user_id, BotState.SLA_TICKET_CREATE)
await max_api.send_message(max_user_id, "🔧 **Заявка по SLA**\n\nПроверяю ваш SLA контракт...")
user = await get_or_create_user(db, max_user_id)
phone = user.phone or ""
if not phone:
await max_api.send_message(max_user_id, "Не могу найти ваш номер телефона. Укажите его, пожалуйста.")
return
from sqlalchemy import select
from app.models.models import Customer, Object, SLAContract
result = await db.execute(
select(Customer).where(Customer.contact_phone.ilike(f"%{phone[-10:]}%")).limit(1)
)
customer = result.scalar_one_or_none()
if not customer:
await _send_no_sla(bot, user_data, db, max_api)
return
result = await db.execute(
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
)
obj = result.scalar_one_or_none()
if not obj:
await _send_no_sla(bot, user_data, db, max_api)
return
result = await db.execute(
select(SLAContract).where(
SLAContract.object_id == obj.id,
SLAContract.status == "active"
).order_by(SLAContract.created_at.desc()).limit(1)
)
sla = result.scalar_one_or_none()
if not sla:
await _send_no_sla(bot, user_data, db, max_api)
return
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
text = await get_template_rendered(db, "sla_ticket_found",
object_name=obj.name,
service_level=level_map.get(sla.service_level, sla.service_level))
await max_api.send_message(max_user_id, text, format="markdown")
return
if not text:
await max_api.send_message(max_user_id, "Опишите неисправность. Вы можете прикрепить фото или документ.")
return
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select, func
from app.models.models import Customer, Object, SLAContract, BotTicket
result = await db.execute(
select(Customer).where(Customer.contact_phone.ilike(f"%{user.phone[-10:]}%")).limit(1)
)
customer = result.scalar_one_or_none()
obj = None
sla = None
if customer:
result = await db.execute(
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
)
obj = result.scalar_one_or_none()
if obj:
result = await db.execute(
select(SLAContract).where(
SLAContract.object_id == obj.id, SLAContract.status == "active"
).order_by(SLAContract.created_at.desc()).limit(1)
)
sla = result.scalar_one_or_none()
priority_map = {"start": "low", "business": "normal", "enterprise": "high"}
response_map = {"start": "4 часа", "business": "2 часа", "enterprise": "30 минут"}
sla_level = sla.service_level if sla else "start"
priority = priority_map.get(sla_level, "normal")
result = await db.execute(select(func.count(BotTicket.id)))
ticket_num = f"T-{result.scalar() + 1:04d}"
from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus
ticket = BotTicket(
ticket_number=ticket_num,
user_id=user.id,
object_id=obj.id if obj else None,
customer_id=customer.id if customer else None,
title=text[:100],
description=text,
priority=priority,
status="open",
created_by="bot",
sla_contract_id=sla.id if sla else None,
has_attachment=user_data.get("has_attachment", False),
attachment_type=user_data.get("attachment_type", ""),
attachment_path=user_data.get("attachment_path", ""),
)
db.add(ticket)
await db.flush()
db.add(BotTicketMessage(ticket_id=ticket.id, direction="in", text=text, sender="client"))
db.add(BotTicketStatus(ticket_id=ticket.id, old_status="", new_status="open", changed_by="bot"))
await db.flush()
has_attachment = user_data.get("has_attachment", False)
attachment_type = user_data.get("attachment_type", "")
attachment_path = user_data.get("attachment_path", "")
if has_attachment:
ticket.has_attachment = True
ticket.attachment_type = attachment_type
ticket.attachment_path = attachment_path
await db.flush()
text = await get_template_rendered(db, "sla_ticket_created",
ticket_number=ticket_num,
priority=priority,
response_time=response_map.get(sla_level, "2 часа"))
await max_api.send_message(max_user_id, text, format="markdown")
from app.max_notifications import notify_ticket_created as nt
await nt(db, ticket, max_api)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _send_no_sla(bot, user_data, db, max_api):
max_user_id = user_data["user_id"]
await SettingsCache.refresh(db)
phone_1 = SettingsCache.get("phone_1")
phone_2 = SettingsCache.get("phone_2")
email = SettingsCache.get("support_email")
text = await get_template_rendered(db, "sla_ticket_not_found",
phone_1=phone_1, phone_2=phone_2,
support_email=email)
from app.keyboards.inline import build_phone_email_buttons
subject = SettingsCache.get("email_subject")
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject)
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
+262 -25
View File
@@ -1,33 +1,270 @@
from app.settings_cache import SettingsCache import logging
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered import datetime
from app.keyboards.inline import build_main_menu_buttons from typing import Optional
from app.fsm import BotState, FSM from sqlalchemy import select
from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory
from app.max_api import max_api
from app.yandex_gpt import yandex_gpt
from app.config_reader import config_reader
from app.settings_cache import settings_cache
from app.keyboards import consent_keyboard, return_to_consent_keyboard
logger = logging.getLogger(__name__)
async def handle_start(bot, user_data: dict, db, max_api): async def handle_greeting(
max_user_id = user_data["user_id"] user_id: int,
first_name = user_data.get("first_name", "") text: Optional[str] = None,
last_name = user_data.get("last_name", "") first_name: str = "",
username = user_data.get("username", "") last_name: str = "",
username: str = "",
) -> None:
assistant_name = await settings_cache.get("assistant_name", "София")
user = await get_or_create_user(db, max_user_id, first_name, last_name, username) greeting_text = (
await db.flush() f"Здравствуйте! Я {assistant_name}, помощник AegisOne Engineering. "
f"Чем я могу быть вам полезна?"
)
await SettingsCache.refresh(db) await max_api.send_message(user_id, greeting_text)
open_conv = await get_open_conversation(db, user.id) async with async_session() as db:
name = SettingsCache.get_assistant_name() existing = await db.execute(
select(BotUser).where(BotUser.id == user_id)
)
user = existing.scalar_one_or_none()
if open_conv: now = datetime.datetime.utcnow()
text = await get_template_rendered(db, "greeting_returning", if not user:
first_name=user.first_name or first_name, user = BotUser(
conv_id=open_conv.id) id=user_id,
buttons = build_main_menu_buttons() first_name=first_name,
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") last_name=last_name,
FSM.set_state(max_user_id, BotState.IDLE) username=username,
last_interaction=now,
total_conversations=1,
created_at=now,
)
db.add(user)
else:
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
if username:
user.username = username
user.last_interaction = now
user.total_conversations = (user.total_conversations or 0) + 1
await db.commit()
await db.refresh(user)
conv = BotConversation(
user_id=user_id,
state="analyzing",
created_at=now,
)
db.add(conv)
await db.commit()
await db.refresh(conv)
if text:
await analyze_and_respond(user_id, conv.id, text)
else:
async with async_session() as db:
conv.state = "awaiting_input"
await db.commit()
async def handle_message(user_id: int, text: str) -> None:
async with async_session() as db:
result = await db.execute(
select(BotConversation)
.where(BotConversation.user_id == user_id)
.order_by(BotConversation.id.desc())
.limit(1)
)
conv = result.scalar_one_or_none()
if not conv:
await handle_greeting(user_id, text)
return return
text = await get_template_rendered(db, "greeting", name=name) msg = BotMessage(
buttons = build_main_menu_buttons() conversation_id=conv.id,
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") direction="incoming",
FSM.set_state(max_user_id, BotState.IDLE) text=text,
created_at=datetime.datetime.utcnow(),
)
db.add(msg)
await db.commit()
state = conv.state if conv else "greeting"
if state in ("greeting", "analyzing", "awaiting_input"):
await analyze_and_respond(user_id, conv.id, text)
elif state == "awaiting_consent":
from app.handlers.consent import handle_consent_response
await handle_consent_response(user_id, conv.id, text)
elif state == "consent_refused":
from app.handlers.consent import handle_refused_again
await handle_refused_again(user_id, conv.id, text)
elif state == "awaiting_contact":
from app.handlers.contact import handle_manual_contact
await handle_manual_contact(user_id, conv.id, text)
elif state == "awaiting_inquiry":
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(user_id, conv.id, text)
elif state in ("completed", "escalated"):
await max_api.send_message(
user_id,
"Ваше обращение уже передано специалисту. "
"Если у вас новый вопрос, напишите его, и я помогу.",
)
async def _get_user_history_context(user_id: int) -> str:
from app.models import BotTicket
async with async_session() as db:
recent_msgs = await db.execute(
select(BotMessage)
.join(BotConversation)
.where(BotConversation.user_id == user_id)
.order_by(BotMessage.id.desc())
.limit(10)
)
messages = list(reversed(recent_msgs.scalars().all()))
open_tickets = await db.execute(
select(BotTicket)
.where(BotTicket.user_id == user_id)
.where(BotTicket.status != "Закрыта")
.order_by(BotTicket.id.desc())
)
tickets = open_tickets.scalars().all()
parts = []
if messages:
msg_lines = []
for m in messages:
who = "Клиент" if m.direction == "incoming" else "Бот"
msg_lines.append(f"{who}: {m.text or ''}")
parts.append("Последние сообщения из прошлых диалогов:\n" + "\n".join(msg_lines))
if tickets:
ticket_lines = []
for t in tickets:
ticket_lines.append(f" - Заявка #{t.id}: {t.title} (статус: {t.status})")
parts.append("Открытые заявки пользователя:\n" + "\n".join(ticket_lines))
return "\n\n".join(parts)
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
gpt_available = await yandex_gpt.is_available()
if not gpt_available:
await _limited_mode(user_id, conv_id)
return
history_context = await _get_user_history_context(user_id)
intent = await yandex_gpt.analyze_intent(text, history_context)
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if not conv:
return
conv.intent = intent
if intent == "question":
kb_context = await _get_knowledge_base_context(text)
answer = await yandex_gpt.generate_answer(text, kb_context, history_context)
if answer:
await max_api.send_message(user_id, answer)
else:
await _send_contacts(user_id)
conv.state = "completed"
elif intent == "ticket":
conv.state = "awaiting_consent"
consent_text = (
"Для обработки Вашего запроса нам нужны ваши контактные данные "
"для обратной связи. Даёте согласие на обработку "
"персональных данных?"
)
await max_api.send_message(
user_id, consent_text, attachments=consent_keyboard()
)
else:
clarification = await yandex_gpt.clarify_intent(text, history_context)
await max_api.send_message(user_id, clarification)
conv.state = "awaiting_consent"
after_text = (
"\n\nДля обработки Вашего запроса нам нужны ваши контактные данные "
"для обратной связи. Даёте согласие на обработку "
"персональных данных?"
)
await max_api.send_message(
user_id, after_text, attachments=consent_keyboard()
)
await db.commit()
async def _limited_mode(user_id: int, conv_id: int) -> None:
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "completed"
await db.commit()
await _send_contacts(user_id)
async def _send_contacts(user_id: int) -> None:
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = "\n".join(
f"📞 {phone} — [позвонить](tel:{link})"
for phone, link in phones
)
message = (
"Вы можете связаться с нами:\n\n"
f"{phone_text}\n"
f"📧 [Написать на почту](mailto:{email})\n\n"
f"Или написать на почту: {email}"
)
await max_api.send_message(user_id, message, format="markdown")
async def _get_knowledge_base_context(query: str) -> str:
async with async_session() as db:
result = await db.execute(
select(BotKnowledgeBase)
.where(BotKnowledgeBase.is_active == True)
.order_by(BotKnowledgeBase.sort_order)
)
cards = result.scalars().all()
if not cards:
return ""
parts = []
for card in cards:
keywords = " ".join(card.keywords) if card.keywords else ""
answer = card.answer or "Нет ответа"
cat_name = card.category.name if card.category else "Общее"
parts.append(f"[{cat_name}] {card.question}: {answer} (ключевые слова: {keywords})")
return "\n\n".join(parts[:20])
+84 -24
View File
@@ -1,32 +1,92 @@
from app.settings_cache import SettingsCache import datetime
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered import logging
from app.fsm import BotState, FSM from sqlalchemy import select
from app.database import async_session
from app.models import BotConversation, BotMessage
from app.max_api import max_api
from app.keyboards import consent_keyboard
logger = logging.getLogger(__name__)
async def handle_handoff_request(bot, user_data: dict, db, max_api): async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
max_user_id = user_data["user_id"] async with async_session() as db:
user = await get_or_create_user(db, max_user_id) result = await db.execute(
select(BotConversation)
.where(BotConversation.user_id == user_id)
.order_by(BotConversation.id.desc())
.limit(1)
)
conv = result.scalar_one_or_none()
if not SettingsCache.is_work_hours(): conv_id = conv.id if conv else 0
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") if callback_id.startswith("consent_"):
await max_api.send_message(max_user_id, text) from app.handlers.consent import handle_consent_callback
await handle_consent_callback(user_id, conv_id, callback_id)
else:
await max_api.send_message(
user_id,
"Извините, я не распознала действие. Пожалуйста, воспользуйтесь кнопками.",
)
conv = await get_open_conversation(db, user.id)
async def handle_contact_share(
user_id: int,
conv_id: int,
phone: str,
vcf_info: str = "",
vcf_data: dict = None,
contact_hash: str = "",
) -> None:
if vcf_data is None:
vcf_data = {}
async with async_session() as db:
from app.models import BotUser
user_result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = user_result.scalar_one_or_none()
if user:
user.phone = phone or user.phone
if vcf_data.get("first_name"):
user.first_name = vcf_data["first_name"]
if vcf_data.get("last_name"):
user.last_name = vcf_data["last_name"]
if vcf_data.get("patronymic"):
user.patronymic = vcf_data["patronymic"]
if vcf_data.get("email"):
user.email = vcf_data["email"]
if vcf_data.get("organization"):
user.organization = vcf_data["organization"]
if vcf_data.get("address"):
user.address = vcf_data["address"]
if vcf_info:
user.vcf_raw = vcf_info
if contact_hash:
user.contact_hash = contact_hash
user.phone_verified = True
if phone:
user.phone_verified = True
msg = BotMessage(
conversation_id=conv_id,
direction="incoming",
text=f"[Контакт поделен] {phone or vcf_data.get('phone', '')}",
created_at=datetime.datetime.utcnow(),
)
db.add(msg)
conv_result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = conv_result.scalar_one_or_none()
if conv: if conv:
conv.status = "handoff" conv.state = "awaiting_inquiry"
conv.priority = "high"
await db.flush()
from app.integrations import _1c_unf as ic_integration await db.commit()
await ic_integration.send_to_1c(db, conv)
FSM.set_state(max_user_id, BotState.HANDOFF_REQUEST) await max_api.send_message(
user_id,
"Спасибо! Ваш контакт получен.\n\n"
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) )
+114 -108
View File
@@ -1,122 +1,128 @@
from app.settings_cache import SettingsCache import datetime
from app.conversation import ( import logging
get_or_create_user, get_open_conversation, create_conversation, from sqlalchemy import select
add_message, auto_categorize_text, get_template_rendered from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage, BotTicket, BotTicketMessage, BotTicketStatus
from app.max_api import max_api
from app.settings_cache import settings_cache
from app.config_reader import config_reader
from app.email_sender import email_sender
logger = logging.getLogger(__name__)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
) )
from app.integrations.yandex_gpt import categorize_with_gpt conv = result.scalar_one_or_none()
from app.keyboards.inline import build_category_buttons if not conv:
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 return
conv = await get_open_conversation(db, user.id) user_result = await db.execute(select(BotUser).where(BotUser.id == user_id))
if not conv: user = user_result.scalar_one_or_none()
conv = await create_conversation(db, user.id)
conv.inquiry_text = text conv.inquiry_text = text
conv.state = "escalated"
await db.commit()
has_attachment = user_data.get("has_attachment", False) await _finalize_ticket(user_id, conv, user, text)
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: async def _finalize_ticket(
try: user_id: int,
file_url = await max_api.get_file_url(attachment_path_str) conv: BotConversation,
if file_url: user: BotUser,
import os inquiry_text: str,
import aiohttp ) -> None:
from app.config import get_settings async with async_session() as db:
from app.models.models import FileSyncQueue user_name = user.first_name or user.last_name or ""
settings = get_settings() ticket_title = f"Обращение от {user_name or 'клиента'} ({user_id})"
local_dir = settings.UPLOADS_DIR if len(inquiry_text) > 100:
os.makedirs(local_dir, exist_ok=True) ticket_title = inquiry_text[:97] + "..."
local_filename = f"conv_{conv.id}_{int(datetime.now().timestamp())}_{attachment_type}"
local_path = os.path.join(local_dir, local_filename) ticket = BotTicket(
async with aiohttp.ClientSession() as session: user_id=user_id,
async with session.get(file_url) as resp: title=ticket_title,
if resp.status == 200: description=inquiry_text,
with open(local_path, "wb") as f: priority="normal",
f.write(await resp.read()) status="Новая",
sync_entry = FileSyncQueue( created_by="bot",
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) db.add(ticket)
except Exception: await db.commit()
pass await db.refresh(ticket)
gpt_key = SettingsCache.get("yandex_gpt_key") msg = BotTicketMessage(
if gpt_key: ticket_id=ticket.id,
cat_name = await categorize_with_gpt(text) direction="incoming",
if cat_name: text=inquiry_text,
from sqlalchemy import select )
from app.models.models import BotCategory db.add(msg)
status_log = BotTicketStatus(
ticket_id=ticket.id,
new_status="Новая",
changed_by="bot",
)
db.add(status_log)
conv_result = await db.execute(
select(BotConversation).where(BotConversation.id == conv.id)
)
conv_obj = conv_result.scalar_one_or_none()
if conv_obj:
conv_obj.state = "completed"
await db.commit()
async with async_session() as db2:
user_obj = await db2.get(BotUser, user_id)
if user_obj:
user_obj.total_tickets = (user_obj.total_tickets or 0) + 1
await db2.commit()
user_info = f"ID: {user_id}\nИмя: {user.first_name or 'не указано'} {user.last_name or ''}"
contact = user.phone or "не указан"
history = await _get_conversation_history(conv.id)
support_email = await settings_cache.get("support_email", "zakaz@aegisone.ru")
email_subject = await settings_cache.get(
"email_subject",
"Обращение через Виртуального помощника AegisOne Engineering",
)
email_sender.send_ticket_escalation(
to=support_email,
subject=email_subject,
user_info=user_info,
contact=contact,
inquiry=inquiry_text,
history=history,
)
await max_api.send_message(
user_id,
f"Зафиксировала: {inquiry_text}\n\n"
f"Ваша заявка отправлена инженеру. В ближайшее время он обязательно "
f"свяжется с вами.",
)
async def _get_conversation_history(conv_id: int) -> str:
async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotCategory).where( select(BotMessage)
BotCategory.name.ilike(f"%{cat_name}%"), .where(BotMessage.conversation_id == conv_id)
BotCategory.active == True .order_by(BotMessage.id)
).limit(1)
) )
cat = result.scalar_one_or_none() messages = result.scalars().all()
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() lines = []
for msg in messages:
direction = "Клиент" if msg.direction == "incoming" else "Бот"
time_str = msg.created_at.strftime("%d.%m.%Y %H:%M") if msg.created_at else ""
lines.append(f"[{time_str}] {direction}: {msg.text or ''}")
ooh = not SettingsCache.is_work_hours() return "\n".join(lines)
if ooh:
start = SettingsCache.get_int("work_hours_start", 9)
end = SettingsCache.get_int("work_hours_end", 18)
ooh_text = await get_template_rendered(db, "out_of_hours",
work_hours_start=start, work_hours_end=end)
await max_api.send_message(max_user_id, ooh_text)
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
async def _confirm_inquiry(bot, user_data, db, max_api, conv, is_ooh: bool):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
if is_ooh:
text = await get_template_rendered(db, "confirmation_ooh",
first_name=user.first_name, conv_id=conv.id)
else:
text = await get_template_rendered(db, "confirmation",
first_name=user.first_name, conv_id=conv.id)
await max_api.send_message(max_user_id, text, format="markdown")
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.set_state(max_user_id, BotState.CONFIRMATION)
-103
View File
@@ -1,103 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.keyboards.inline import build_main_menu_buttons
from app.fsm import BotState, FSM
async def handle_main_menu(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if payload == "new_inquiry":
user = await get_or_create_user(db, max_user_id)
if user.consent_given:
from app.handlers.contact import handle_contact_provided
await handle_contact_provided(bot, user_data, db, max_api)
else:
from app.handlers.consent import handle_consent_request
await handle_consent_request(bot, user_data, db, max_api)
return
if payload == "handoff":
from app.handlers.handoff import handle_handoff_request
await handle_handoff_request(bot, user_data, db, max_api)
return
if payload == "kb_search":
FSM.set_state(max_user_id, BotState.KB_SEARCH)
await max_api.send_message(max_user_id, "Задайте ваш вопрос, и я постараюсь найти ответ.")
return
if payload.startswith("feature_"):
feature_key = payload.replace("feature_", "")
if SettingsCache.is_feature_enabled(feature_key):
await _handle_feature(bot, user_data, db, max_api, feature_key)
else:
await max_api.send_message(max_user_id, "Этот функционал временно недоступен.")
return
if payload.startswith("cat_"):
await _handle_category_select(bot, user_data, db, max_api, payload)
return
if payload == "cancel":
FSM.reset(max_user_id)
text = await get_template_rendered(db, "main_menu")
buttons = build_main_menu_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
return
FSM.reset(max_user_id)
text = await get_template_rendered(db, "main_menu")
buttons = build_main_menu_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
async def _handle_feature(bot, user_data, db, max_api, feature_key: str):
feature_handlers = {
"risk_score": ("app.handlers.features.risk_score", "handle_risk_score"),
"sla_status": ("app.handlers.features.sla_status", "handle_sla_status"),
"photo_report": ("app.handlers.features.photo_report", "handle_photo_report"),
"appointment": ("app.handlers.features.appointment", "handle_appointment"),
"quality_rate": ("app.handlers.features.quality_rate", "handle_quality_rate"),
"commercial_offer": ("app.handlers.features.commercial", "handle_commercial"),
"my_objects": ("app.handlers.features.my_objects", "handle_my_objects"),
"reminders": ("app.handlers.features.reminders", "handle_reminders"),
"sla_ticket": ("app.handlers.features.sla_ticket", "handle_sla_ticket"),
"notifications": ("app.handlers.features.notifications", "handle_notifications"),
"broadcasts": ("app.handlers.features.broadcasts", "handle_broadcast_send"),
}
handler = feature_handlers.get(feature_key)
if handler:
import importlib
mod = importlib.import_module(handler[0])
func = getattr(mod, handler[1])
await func(bot, user_data, db, max_api)
else:
await max_api.send_message(max_user_id, "Функция в разработке.")
async def _handle_category_select(bot, user_data, db, max_api, payload: str):
max_user_id = user_data["user_id"]
try:
cat_id = int(payload.replace("cat_", ""))
except ValueError:
return
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(select(BotCategory).where(BotCategory.id == cat_id))
cat = result.scalar_one_or_none()
if not cat:
return
from app.conversation import get_open_conversation, get_or_create_user
user = await get_or_create_user(db, max_user_id)
conv = await get_open_conversation(db, user.id)
if conv:
conv.inquiry_type = cat.id
await db.flush()
ooh = not SettingsCache.is_work_hours()
from app.handlers.inquiry import _confirm_inquiry
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
-43
View File
@@ -1,43 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_template_rendered
from app.keyboards.inline import build_cancel_button
from app.fsm import BotState, FSM
from app.handlers.main_menu import handle_main_menu
async def handle_out_of_hours(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
await SettingsCache.refresh(db)
start = SettingsCache.get_int("work_hours_start", 9)
end = SettingsCache.get_int("work_hours_end", 18)
hours_str = SettingsCache.get_work_hours_str()
text = (
f"Сейчас мы не работаем. Наше рабочее время: Пн-Пт {hours_str} (МСК).\n\n"
f"Оставьте сообщение — мы ответим как можно быстрее."
)
buttons = [
[{"type": "message", "text": "✉️ Оставить сообщение", "payload": "leave_message"}],
[{"type": "message", "text": "📞 Заказать звонок", "payload": "callback"}],
]
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
FSM.set_state(max_user_id, BotState.OUT_OF_HOURS)
async def handle_out_of_hours_callback(bot, user_data: dict, db, max_api, payload: str):
max_user_id = user_data["user_id"]
if payload == "leave_message":
FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST)
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(bot, user_data, db, max_api)
return
if payload == "callback":
from app.handlers.consent import handle_consent_request
FSM.set_state(max_user_id, BotState.NAME_REQUEST)
await max_api.send_message(max_user_id, "Как к вам обращаться?")
return
await handle_main_menu(bot, user_data, db, max_api)
-100
View File
@@ -1,100 +0,0 @@
import json
import logging
from datetime import datetime
from typing import Optional
import aiohttp
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import BotConversation, Bot1CSyncLog, BotSetting
from app.settings_cache import SettingsCache
logger = logging.getLogger(__name__)
async def send_to_1c(db: AsyncSession, conversation: BotConversation) -> Optional[dict]:
webhook_url = SettingsCache.get("1c_webhook_url")
if not webhook_url:
return None
user = conversation.user
category = conversation.category
payload = {
"conversation_id": conversation.id,
"user": {
"max_user_id": user.max_user_id,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"email": user.email,
},
"inquiry": {
"text": conversation.inquiry_text,
"category": category.name if category else "",
"priority": conversation.priority,
"has_attachment": conversation.has_attachment,
"attachment_type": conversation.attachment_type,
},
"created_at": conversation.created_at.isoformat() if conversation.created_at else "",
}
sync_log = Bot1CSyncLog(
conversation_id=conversation.id,
status="pending",
request_payload=json.dumps(payload, ensure_ascii=False),
)
db.add(sync_log)
await db.flush()
try:
async with aiohttp.ClientSession() as session:
async with session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
response_text = await resp.text()
sync_log.status = "sent" if resp.status == 200 else "error"
sync_log.response_payload = response_text
sync_log.sent_at = datetime.now()
await db.flush()
if resp.status == 200:
try:
return await resp.json()
except Exception:
return {"raw": response_text}
logger.warning(f"1C webhook returned {resp.status}: {response_text[:500]}")
return None
except Exception as e:
logger.error(f"1C webhook error: {e}")
sync_log.status = "error"
sync_log.response_payload = str(e)
sync_log.sent_at = datetime.now()
await db.flush()
return None
async def handle_1c_response(db: AsyncSession, data: dict) -> bool:
conversation_id = data.get("conversation_id")
if not conversation_id:
return False
result = await db.execute(select(BotConversation).where(BotConversation.id == int(conversation_id)))
conv = result.scalar_one_or_none()
if not conv:
return False
sync_log = Bot1CSyncLog(
conversation_id=conv.id,
status="confirmed",
response_payload=json.dumps(data, ensure_ascii=False),
confirmed_at=datetime.now(),
)
db.add(sync_log)
status = data.get("status", "")
if status:
conv.status = status
assigned = data.get("assigned_to", "")
if assigned:
conv.assigned_to = assigned
await db.flush()
return True
-57
View File
@@ -1,57 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import Object, SLAContract, Task, Incident, Customer
async def get_object_by_name_or_id(db: AsyncSession, query: str) -> Object:
try:
obj_id = int(query)
return await db.get(Object, obj_id)
except (ValueError, TypeError):
result = await db.execute(
select(Object).where(Object.name.ilike(f"%{query}%")).limit(1)
)
return result.scalar_one_or_none()
async def get_sla_for_object(db: AsyncSession, object_id: int) -> SLAContract:
result = await db.execute(
select(SLAContract).where(
SLAContract.object_id == object_id,
SLAContract.status == "active"
).order_by(SLAContract.created_at.desc()).limit(1)
)
return result.scalar_one_or_none()
async def get_objects_for_customer(db: AsyncSession, customer_name: str) -> list:
result = await db.execute(
select(Customer).where(Customer.name.ilike(f"%{customer_name}%")).limit(1)
)
customer = result.scalar_one_or_none()
if not customer:
return []
result = await db.execute(
select(Object).where(Object.customer_id == customer.id, Object.status == "active")
)
return result.scalars().all()
async def get_active_tasks_for_object(db: AsyncSession, object_id: int) -> list:
result = await db.execute(
select(Task).where(
Task.object_id == object_id,
Task.status.in_(["open", "in_progress"])
)
)
return result.scalars().all()
async def get_last_incident_for_object(db: AsyncSession, object_id: int):
result = await db.execute(
select(Incident).where(
Incident.object_id == object_id,
Incident.status.in_(["resolved", "closed"])
).order_by(Incident.created_at.desc()).limit(1)
)
return result.scalar_one_or_none()
-80
View File
@@ -1,80 +0,0 @@
import aiohttp
import logging
from app.settings_cache import SettingsCache
logger = logging.getLogger(__name__)
async def categorize_with_gpt(text: str) -> str:
gpt_key = SettingsCache.get("yandex_gpt_key")
folder_id = SettingsCache.get("yandex_folder_id")
if not gpt_key or not folder_id:
return ""
prompt = (
"Определи категорию обращения клиента из списка: "
"Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, "
"Обслуживание, Консультация, Другое. "
"Ответь только названием категории, без пояснений. "
f"Текст обращения: {text}"
)
url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
headers = {
"Authorization": f"Api-Key {gpt_key}",
"Content-Type": "application/json",
"x-folder-id": folder_id,
}
body = {
"modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest",
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 50},
"messages": [{"role": "user", "text": prompt}],
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip()
logger.warning(f"GPT categorize returned {resp.status}")
except Exception as e:
logger.error(f"GPT categorize error: {e}")
return ""
async def analyze_emotion_with_gpt(text: str) -> str:
gpt_key = SettingsCache.get("yandex_gpt_key")
folder_id = SettingsCache.get("yandex_folder_id")
if not gpt_key or not folder_id:
return "neutral"
prompt = (
"Определи эмоциональную окраску текста клиента. "
"Варианты: positive, neutral, negative. "
"Ответь только одним словом. "
f"Текст: {text}"
)
url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
headers = {
"Authorization": f"Api-Key {gpt_key}",
"Content-Type": "application/json",
"x-folder-id": folder_id,
}
body = {
"modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest",
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 10},
"messages": [{"role": "user", "text": prompt}],
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip().lower()
logger.warning(f"GPT emotion analysis returned {resp.status}")
except Exception as e:
logger.error(f"GPT emotion analysis error: {e}")
return "neutral"
+77
View File
@@ -0,0 +1,77 @@
from typing import Optional, List, Dict, Any
POLITICA_URL = "https://aegisone.ru/politica.php"
def _callback_button(text: str, payload: str, intent: str = "default") -> dict:
return {
"type": "callback",
"text": text,
"payload": payload,
"intent": intent,
}
def _link_button(text: str, url: str) -> dict:
return {
"type": "link",
"text": text,
"url": url,
}
def _request_contact_button(text: str) -> dict:
return {
"type": "request_contact",
"text": text,
}
def _message_button(text: str) -> dict:
return {
"type": "message",
"text": text,
}
def make_inline_keyboard(buttons: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
return [
{
"type": "inline_keyboard",
"payload": {"buttons": buttons},
}
]
def consent_keyboard() -> list:
return make_inline_keyboard([
[
_callback_button("✅ Даю согласие", "consent_yes", intent="positive"),
_link_button("📄 Подробнее", POLITICA_URL),
_callback_button("❌ Не даю согласие", "consent_no", intent="negative"),
],
])
def contact_keyboard() -> list:
return make_inline_keyboard([
[
_request_contact_button("📱 Поделиться контактом"),
],
])
def single_button_keyboard(text: str, payload: str, intent: str = "default") -> list:
return make_inline_keyboard([
[
_callback_button(text, payload, intent),
],
])
def return_to_consent_keyboard() -> list:
return make_inline_keyboard([
[
_callback_button("✅ Даю согласие", "consent_yes", intent="positive"),
],
])
View File
-90
View File
@@ -1,90 +0,0 @@
from app.settings_cache import SettingsCache
def build_main_menu_buttons() -> list:
buttons = []
row = [
{"type": "message", "text": "📋 Оставить заявку", "payload": "new_inquiry"},
{"type": "message", "text": "📞 Связаться с оператором", "payload": "handoff"},
]
buttons.append(row)
features = SettingsCache.get_enabled_features()
feature_buttons = []
feature_map = {
"risk_score": ("📊 Risk Score", "feature_risk_score"),
"sla_status": ("🔍 SLA статус", "feature_sla_status"),
"photo_report": ("📸 Фото-отчёт", "feature_photo_report"),
"appointment": ("📅 Запись на визит", "feature_appointment"),
"quality_rate": ("⭐ Оценка качества", "feature_quality_rate"),
"commercial_offer": ("📄 Коммерческое предложение", "feature_commercial"),
"my_objects": ("🏢 Мои объекты", "feature_my_objects"),
"reminders": ("🔔 Напоминания", "feature_reminders"),
"notifications": ("🔔 Уведомления", "feature_notifications"),
"broadcasts": ("📢 Рассылки", "feature_broadcast"),
}
for key, (label, payload) in feature_map.items():
if key in features:
feature_buttons.append({"type": "message", "text": label, "payload": payload})
while feature_buttons:
buttons.append(feature_buttons[:2])
feature_buttons = feature_buttons[2:]
row = [{"type": "message", "text": "❓ Задать вопрос", "payload": "kb_search"}]
buttons.append(row)
return buttons
def build_consent_buttons() -> list:
return [[
{"type": "message", "text": "✅ Даю согласие", "payload": "consent_given"},
], [
{"type": "message", "text": "❌ Не даю согласие", "payload": "consent_refused"},
]]
def build_contact_buttons() -> list:
return [[
{"type": "request_contact", "text": "📱 Поделиться контактом"},
]]
def build_category_buttons() -> list:
categories = SettingsCache.get_categories()
buttons = []
row = []
for cat in categories:
row.append({"type": "message", "text": f"{cat.icon_emoji} {cat.name}", "payload": f"cat_{cat.id}"})
if len(row) >= 2:
buttons.append(row)
row = []
if row:
buttons.append(row)
return buttons
def build_quality_buttons() -> list:
return [[
{"type": "callback", "text": "", "payload": "rate_1"},
{"type": "callback", "text": "⭐⭐", "payload": "rate_2"},
{"type": "callback", "text": "⭐⭐⭐", "payload": "rate_3"},
], [
{"type": "callback", "text": "⭐⭐⭐⭐", "payload": "rate_4"},
{"type": "callback", "text": "⭐⭐⭐⭐⭐", "payload": "rate_5"},
]]
def build_cancel_button() -> list:
return [[{"type": "message", "text": "↩️ Отмена", "payload": "cancel"}]]
def build_phone_email_buttons(phone_1: str, phone_2: str, email: str, subject: str) -> list:
from urllib.parse import quote
buttons = [
[{"type": "link", "text": f"📞 {phone_1}", "url": f"tel:{phone_1.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}],
[{"type": "link", "text": f"📞 {phone_2}", "url": f"tel:{phone_2.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}],
[{"type": "link", "text": f"✉️ {email}", "url": f"mailto:{email}?subject={quote(subject)}"}],
]
return buttons
+468 -800
View File
File diff suppressed because it is too large Load Diff
+81 -143
View File
@@ -1,159 +1,97 @@
import os import httpx
import re
import hmac
import hashlib
import logging
from datetime import datetime
from typing import Optional from typing import Optional
from app.config import settings
import aiohttp
logger = logging.getLogger(__name__)
MAX_API_URL = "https://platform-api.max.ru"
class MaxAPIClient: class MaxAPI:
def __init__(self, token: str, test_mode: bool = False): def __init__(self):
self.token = token self.base_url = settings.max_api_base
self.test_mode = test_mode self.token = settings.max_token
self._session: Optional[aiohttp.ClientSession] = None self.client = httpx.AsyncClient(timeout=30.0)
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: def _headers(self) -> dict:
return {"Authorization": self.token, "Content-Type": "application/json"} return {
"Authorization": self.token,
async def _request(self, method: str, path: str, **kwargs) -> dict: "Content-Type": "application/json",
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: async def get_me(self) -> dict:
return await self._request("GET", "/me") r = await self.client.get(f"{self.base_url}/me", headers=self._headers())
r.raise_for_status()
return r.json()
async def send_message(self, user_id: int = None, chat_id: int = None, text: str = "", async def subscribe_webhook(self) -> dict:
attachments: list = None, format: str = None, payload = {
disable_link_preview: bool = False, notify: bool = True) -> dict: "url": settings.webhook_url,
body = {"text": text} "update_types": settings.update_types,
if attachments: "secret": settings.webhook_secret,
body["attachments"] = attachments }
if format: r = await self.client.post(
body["format"] = format f"{self.base_url}/subscriptions",
if not disable_link_preview: headers=self._headers(),
body["disable_link_preview"] = False json=payload,
if not notify: )
body["notify"] = False r.raise_for_status()
return r.json()
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: async def unsubscribe_webhook(self) -> dict:
return await self._request("DELETE", "/subscriptions") r = await self.client.delete(
f"{self.base_url}/subscriptions",
headers=self._headers(),
)
r.raise_for_status()
return r.json()
async def upload_file(self, file_path: str) -> dict: async def get_subscriptions(self) -> dict:
session = await self._get_session() r = await self.client.get(
url = f"{MAX_API_URL}/uploads" f"{self.base_url}/subscriptions",
with open(file_path, "rb") as f: headers=self._headers(),
data = aiohttp.FormData() )
data.add_field("file", f, filename=os.path.basename(file_path)) r.raise_for_status()
async with session.post(url, headers={"Authorization": self.token}, data=data) as resp: return r.json()
resp.raise_for_status()
return await resp.json()
def verify_webhook_secret(self, header_value: str, expected_secret: str) -> bool: async def send_message(
if not expected_secret: self,
return True user_id: int,
return hmac.compare_digest(header_value, expected_secret) text: str,
attachments: Optional[list] = None,
format: Optional[str] = None,
notify: bool = True,
) -> dict:
payload = {"text": text, "notify": notify}
if attachments:
payload["attachments"] = attachments
if format:
payload["format"] = format
r = await self.client.post(
f"{self.base_url}/messages?user_id={user_id}",
headers=self._headers(),
json=payload,
)
r.raise_for_status()
return r.json()
async def send_file(self, user_id: int, file_path: str, caption: str = "") -> dict: async def answer_callback(
upload = await self.upload_file(file_path) self,
file_id = upload.get("file_id", "") callback_id: str,
body = {"text": caption, "attachments": [{"type": "file", "payload": {"file_id": file_id}}]} message: Optional[dict] = None,
return await self._request("POST", "/messages", json=body, params={"user_id": user_id}) notification: Optional[str] = None,
) -> dict:
payload = {}
if message is not None:
payload["message"] = message
if notification is not None:
payload["notification"] = notification
r = await self.client.post(
f"{self.base_url}/answers?callback_id={callback_id}",
headers=self._headers(),
json=payload,
)
r.raise_for_status()
return r.json()
async def get_file_url(self, file_id: str) -> str: async def close(self):
resp = await self._request("GET", f"/uploads/{file_id}") await self.client.aclose()
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: max_api = MaxAPI()
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)
-148
View File
@@ -1,148 +0,0 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import (
BotNotification, BotNotificationSubscription, BotUser,
)
from app.settings_cache import SettingsCache
try:
from app.models.models import User
except ImportError:
User = None
async def _get_max_user_id_by_role(db: AsyncSession, role: str) -> int | None:
if User is None:
return None
result = await db.execute(
select(User.max_user_id).where(
User.role == role, User.is_active == True,
User.max_user_id.isnot(None)
).limit(1)
)
return result.scalar_one_or_none()
async def _get_user_max_ids_by_role(db: AsyncSession, role: str) -> list[int]:
if User is None:
return []
result = await db.execute(
select(User.max_user_id).where(
User.role == role, User.is_active == True,
User.max_user_id.isnot(None)
)
)
return [row[0] for row in result.fetchall() if row[0]]
async def _log_notification(db: AsyncSession, ntype: str, recipient_type: str,
recipient_max_user_id: int | None, title: str, body: str,
related_type: str = "", related_id: int | None = None):
n = BotNotification(
type=ntype, recipient_type=recipient_type,
recipient_max_user_id=recipient_max_user_id,
title=title, body=body,
related_type=related_type, related_id=related_id
)
db.add(n)
await db.flush()
return n
async def notify_operator(db: AsyncSession, max_api, text: str, title: str = "",
related_type: str = "", related_id: int | None = None):
max_ids = await _get_user_max_ids_by_role(db, "owner")
sent_count = 0
for mid in max_ids:
try:
await max_api.send_message(user_id=mid, text=text, format="markdown")
sent_count += 1
except Exception:
pass
await _log_notification(
db, "operator_alert", "operator", max_ids[0] if max_ids else None,
title or "Уведомление оператору", text,
related_type, related_id
)
return sent_count
async def notify_level2(db: AsyncSession, max_api, text: str, title: str = "",
related_type: str = "", related_id: int | None = None):
max_ids = await _get_user_max_ids_by_role(db, "engineer")
sent_count = 0
for mid in max_ids:
try:
await max_api.send_message(user_id=mid, text=text, format="markdown")
sent_count += 1
except Exception:
pass
await _log_notification(
db, "level2_alert", "level2", max_ids[0] if max_ids else None,
title or "Уведомление инженеру", text,
related_type, related_id
)
return sent_count
async def notify_client_status_change(db: AsyncSession, max_api,
bot_user: BotUser, conv_id: int,
new_status: str):
text = f"Статус вашего обращения №{conv_id} изменён на «{new_status}»."
try:
await max_api.send_message(user_id=bot_user.max_user_id, text=text, format="markdown")
except Exception:
pass
await _log_notification(
db, "status_change", "client", bot_user.max_user_id,
"Статус обращения", text, "conversation", conv_id
)
async def notify_ticket_created(db: AsyncSession, ticket, max_api):
ticket_text = (
f"🆕 **Новый тикет {ticket.ticket_number}**\n"
f"Приоритет: {ticket.priority}\n"
f"Описание: {ticket.description[:200]}\n"
f"Обращение №{ticket.id}"
)
await notify_level2(
db, max_api, ticket_text,
title=f"Тикет {ticket.ticket_number}",
related_type="ticket", related_id=ticket.id
)
async def notify_handoff_escalation(db: AsyncSession, max_api,
bot_user: BotUser, conv_id: int,
text: str = ""):
msg = (
f"🔴 **Эскалация оператору**\n"
f"Пользователь: {bot_user.first_name or ''} (ID {bot_user.max_user_id})\n"
f"Обращение №{conv_id}\n"
f"Текст: {text[:200] if text else ''}"
)
await notify_operator(
db, max_api, msg,
title="Эскалация",
related_type="conversation", related_id=conv_id
)
async def get_subscribed_users(db: AsyncSession, notify_type: str = "broadcast") -> list[BotUser]:
col_map = {
"broadcast": BotNotificationSubscription.notify_on_broadcast,
"status_change": BotNotificationSubscription.notify_on_status_change,
"reply": BotNotificationSubscription.notify_on_reply,
}
col = col_map.get(notify_type)
if col is None:
return []
result = await db.execute(
select(BotUser).join(
BotNotificationSubscription,
BotNotificationSubscription.user_id == BotUser.id
).where(col == True)
)
return list(result.scalars().all())
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class BotSetting(Base):
__tablename__ = "bot_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class BotFeature(Base):
__tablename__ = "bot_features"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(255), unique=True, nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
is_enabled = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class BotCategory(Base):
__tablename__ = "bot_categories"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class BotKnowledgeBase(Base):
__tablename__ = "bot_knowledge_base"
id = Column(Integer, primary_key=True, autoincrement=True)
category_id = Column(Integer, ForeignKey("bot_categories.id"), nullable=True)
question = Column(Text, nullable=False)
answer = Column(Text, nullable=True)
keywords = Column(ARRAY(String), nullable=True)
is_active = Column(Boolean, default=True)
source_url = Column(String(500), nullable=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
category = relationship("BotCategory", lazy="joined")
class BotUser(Base):
__tablename__ = "bot_users"
id = Column(BigInteger, primary_key=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
patronymic = Column(String(255), nullable=True)
username = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
organization = Column(String(255), nullable=True)
address = Column(Text, nullable=True)
vcf_raw = Column(Text, nullable=True)
contact_hash = Column(String(255), nullable=True)
phone_verified = Column(Boolean, default=False)
email_verified = Column(Boolean, default=False)
consent_given = Column(Boolean, default=False)
consent_date = Column(DateTime, nullable=True)
last_interaction = Column(DateTime, nullable=True)
total_conversations = Column(Integer, default=0)
total_tickets = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class BotConversation(Base):
__tablename__ = "bot_conversations"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("bot_users.id"), nullable=False)
state = Column(String(50), default="greeting")
intent = Column(String(50), nullable=True)
inquiry_text = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
user = relationship("BotUser", lazy="joined")
class BotMessage(Base):
__tablename__ = "bot_messages"
id = Column(BigInteger, primary_key=True, autoincrement=True)
conversation_id = Column(BigInteger, ForeignKey("bot_conversations.id"), nullable=False)
direction = Column(String(10), nullable=False)
text = Column(Text, nullable=True)
attachment_json = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
conversation = relationship("BotConversation", lazy="joined")
class BotTicket(Base):
__tablename__ = "bot_tickets"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("bot_users.id"), nullable=False)
title = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
priority = Column(String(20), default="normal")
status = Column(String(20), default="Новая")
created_by = Column(String(100), default="bot")
assigned_to = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
user = relationship("BotUser", lazy="joined")
class BotTicketMessage(Base):
__tablename__ = "bot_ticket_messages"
id = Column(BigInteger, primary_key=True, autoincrement=True)
ticket_id = Column(BigInteger, ForeignKey("bot_tickets.id"), nullable=False)
direction = Column(String(10), nullable=False)
text = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
ticket = relationship("BotTicket", lazy="joined")
class BotTicketStatus(Base):
__tablename__ = "bot_ticket_statuses"
id = Column(BigInteger, primary_key=True, autoincrement=True)
ticket_id = Column(BigInteger, ForeignKey("bot_tickets.id"), nullable=False)
old_status = Column(String(20), nullable=True)
new_status = Column(String(20), nullable=False)
changed_by = Column(String(100), nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
ticket = relationship("BotTicket", lazy="joined")

Some files were not shown because too many files have changed in this diff Show More