v1.6.0: max_bot fixes — feature keys, flush→commit, test-run, categories, broadcast page, proxy error handling, deploy scripts
This commit is contained in:
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,268 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.max_api import MaxAPIClient
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.fsm import BotState, FSM
|
||||
from app.conversation import (
|
||||
get_or_create_user, add_message,
|
||||
get_template_rendered, find_in_knowledge_base
|
||||
)
|
||||
from app.utils.emotion_detector import detect_emotion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_update(bot, update: dict, db, max_api: MaxAPIClient):
|
||||
update_type = update.get("update_type", "")
|
||||
user_data = update.get("user", {})
|
||||
max_user_id = user_data.get("user_id")
|
||||
|
||||
if not max_user_id:
|
||||
return
|
||||
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
if update_type == "bot_started":
|
||||
await _handle_bot_started(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if update_type == "message_created":
|
||||
await _handle_message_created(bot, user_data, update, db, max_api)
|
||||
return
|
||||
|
||||
if update_type == "message_callback":
|
||||
await _handle_callback(bot, user_data, update, db, max_api)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_bot_started(bot, user_data, db, max_api):
|
||||
await SettingsCache.refresh(db)
|
||||
if not SettingsCache.is_work_hours():
|
||||
from app.handlers.out_of_hours import handle_out_of_hours
|
||||
await handle_out_of_hours(bot, user_data, db, max_api)
|
||||
return
|
||||
from app.handlers.greeting import handle_start
|
||||
await handle_start(bot, user_data, db, max_api)
|
||||
|
||||
|
||||
async def _handle_message_created(bot, user_data, update, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
message = update.get("message", {})
|
||||
body = message.get("body", {})
|
||||
text = body.get("text", "").strip()
|
||||
attachments = body.get("attachments", [])
|
||||
|
||||
user_data["text"] = text
|
||||
user_data["ip"] = update.get("ip", "")
|
||||
|
||||
has_attachment = len(attachments) > 0
|
||||
attachment_type = ""
|
||||
attachment_path = ""
|
||||
|
||||
if has_attachment:
|
||||
att = attachments[0]
|
||||
attachment_type = att.get("type", "")
|
||||
if attachment_type == "contact":
|
||||
payload = att.get("payload", {})
|
||||
vcf_info = payload.get("vcf_info", "")
|
||||
import re
|
||||
phone_match = re.search(r'TEL[^:]*:(\+?\d+)', vcf_info)
|
||||
name_match = re.search(r'FN:(.+)', vcf_info)
|
||||
if phone_match:
|
||||
user_data["phone"] = phone_match.group(1)
|
||||
if name_match:
|
||||
user_data["first_name"] = name_match.group(1).strip()
|
||||
await _handle_contact_shared(bot, user_data, db, max_api)
|
||||
return
|
||||
attachment_path = att.get("url", "")
|
||||
|
||||
user_data["has_attachment"] = has_attachment
|
||||
user_data["attachment_type"] = attachment_type
|
||||
user_data["attachment_path"] = attachment_path
|
||||
|
||||
state = FSM.get_state(max_user_id)
|
||||
|
||||
if state == BotState.IDLE:
|
||||
if text.lower() in ("/start", "/help"):
|
||||
if not SettingsCache.is_work_hours():
|
||||
from app.handlers.out_of_hours import handle_out_of_hours
|
||||
await handle_out_of_hours(bot, user_data, db, max_api)
|
||||
return
|
||||
from app.handlers.greeting import handle_start
|
||||
await handle_start(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if not SettingsCache.is_work_hours():
|
||||
from app.handlers.out_of_hours import handle_out_of_hours
|
||||
await handle_out_of_hours(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
emotion = await detect_emotion(text)
|
||||
if emotion == "negative":
|
||||
tmpl = await get_template_rendered(db, "emotion_escalate")
|
||||
await max_api.send_message(max_user_id, tmpl)
|
||||
from app.handlers.handoff import handle_handoff_request
|
||||
await handle_handoff_request(bot, user_data, db, max_api)
|
||||
from app.max_notifications import notify_handoff_escalation
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
conv = None
|
||||
try:
|
||||
from app.conversation import get_open_conversation
|
||||
conv = await get_open_conversation(db, user.id)
|
||||
except Exception:
|
||||
pass
|
||||
await notify_handoff_escalation(
|
||||
db, max_api, user,
|
||||
conv.id if conv else 0, text
|
||||
)
|
||||
return
|
||||
|
||||
kb_entry = await find_in_knowledge_base(db, text)
|
||||
if kb_entry:
|
||||
await max_api.send_message(max_user_id, kb_entry.answer, format="markdown")
|
||||
return
|
||||
|
||||
from app.conversation import get_or_create_user
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
if not user.consent_given:
|
||||
from app.handlers.greeting import handle_start
|
||||
await handle_start(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
user_data["payload"] = "new_inquiry"
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.NAME_REQUEST:
|
||||
if not text:
|
||||
await max_api.send_message(max_user_id, "Как к вам обращаться?")
|
||||
return
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
user.first_name = text
|
||||
await db.flush()
|
||||
from app.handlers.consent import handle_consent_request
|
||||
await handle_consent_request(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.CONSENT_REQUEST:
|
||||
if text.lower() in ("да", "даю согласие", "согласен", "yes"):
|
||||
from app.handlers.consent import handle_consent_given
|
||||
await handle_consent_given(bot, user_data, db, max_api)
|
||||
elif text.lower() in ("нет", "не даю", "не согласен", "no"):
|
||||
from app.handlers.consent import handle_consent_refused
|
||||
await handle_consent_refused(bot, user_data, db, max_api)
|
||||
else:
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
user_data["payload"] = text.lower()
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.CONTACT_REQUEST:
|
||||
from app.handlers.contact import handle_contact_text
|
||||
await handle_contact_text(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.INQUIRY_REQUEST:
|
||||
from app.handlers.inquiry import handle_inquiry
|
||||
await handle_inquiry(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.CATEGORY_SELECT:
|
||||
from app.handlers.inquiry import handle_inquiry
|
||||
await handle_inquiry(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.OUT_OF_HOURS:
|
||||
from app.handlers.out_of_hours import handle_out_of_hours_callback
|
||||
await handle_out_of_hours_callback(bot, user_data, db, max_api, text.lower())
|
||||
return
|
||||
|
||||
_feature_handlers = {
|
||||
BotState.FEATURE_RISK_SCORE: ("app.handlers.features.risk_score", "handle_risk_score"),
|
||||
BotState.FEATURE_SLA_STATUS: ("app.handlers.features.sla_status", "handle_sla_status"),
|
||||
BotState.FEATURE_PHOTO_REPORT: ("app.handlers.features.photo_report", "handle_photo_report"),
|
||||
BotState.FEATURE_APPOINTMENT: ("app.handlers.features.appointment", "handle_appointment"),
|
||||
BotState.FEATURE_COMMERCIAL: ("app.handlers.features.commercial", "handle_commercial"),
|
||||
BotState.FEATURE_MY_OBJECTS: ("app.handlers.features.my_objects", "handle_my_objects"),
|
||||
BotState.FEATURE_REMINDERS: ("app.handlers.features.reminders", "handle_reminders"),
|
||||
BotState.FEATURE_QUALITY_RATE: ("app.handlers.features.quality_rate", "handle_quality_rate"),
|
||||
BotState.FEATURE_NOTIFICATIONS: ("app.handlers.features.notifications", "handle_notifications"),
|
||||
BotState.FEATURE_BROADCAST: ("app.handlers.features.broadcasts", "handle_broadcast_send"),
|
||||
BotState.SLA_TICKET_CREATE: ("app.handlers.features.sla_ticket", "handle_sla_ticket"),
|
||||
}
|
||||
if state in _feature_handlers:
|
||||
mod_path, func_name = _feature_handlers[state]
|
||||
import importlib
|
||||
mod = importlib.import_module(mod_path)
|
||||
func = getattr(mod, func_name)
|
||||
await func(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if state == BotState.KB_SEARCH:
|
||||
kb_entry = await find_in_knowledge_base(db, text)
|
||||
if kb_entry:
|
||||
await max_api.send_message(max_user_id, kb_entry.answer, format="markdown")
|
||||
FSM.transition(max_user_id, "found")
|
||||
else:
|
||||
await max_api.send_message(max_user_id,
|
||||
"К сожалению, не нашёл ответа на ваш вопрос. "
|
||||
"Опишите подробнее или нажмите «Оставить заявку» для создания обращения.")
|
||||
FSM.transition(max_user_id, "not_found")
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
return
|
||||
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
user_data["payload"] = text.lower()
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
|
||||
|
||||
async def _handle_callback(bot, user_data, update, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
callback = update.get("callback", {})
|
||||
payload = callback.get("payload", "")
|
||||
callback_id = callback.get("id", "")
|
||||
|
||||
user_data["payload"] = payload
|
||||
|
||||
if payload == "cancel":
|
||||
FSM.reset(max_user_id)
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
await max_api.answer_callback(callback_id)
|
||||
return
|
||||
|
||||
if payload.startswith("rate_"):
|
||||
from app.handlers.features.quality_rate import handle_quality_rate
|
||||
await handle_quality_rate(bot, user_data, db, max_api)
|
||||
await max_api.answer_callback(callback_id)
|
||||
return
|
||||
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
await max_api.answer_callback(callback_id)
|
||||
|
||||
|
||||
async def _handle_contact_shared(bot, user_data, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
if user_data.get("phone"):
|
||||
user.phone = user_data["phone"]
|
||||
if user_data.get("first_name"):
|
||||
user.first_name = user_data["first_name"]
|
||||
await db.flush()
|
||||
|
||||
state = FSM.get_state(max_user_id)
|
||||
if state == BotState.CONTACT_REQUEST:
|
||||
from app.handlers.contact import handle_contact_provided
|
||||
await handle_contact_provided(bot, user_data, db, max_api)
|
||||
else:
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
user_data["payload"] = "new_inquiry"
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone"
|
||||
APP_ENV: str = "development"
|
||||
LOG_LEVEL: str = "debug"
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
WEBHOOK_SECRET: str = "change-me-to-random-secret"
|
||||
UPLOADS_DIR: str = str(Path(__file__).parent.parent / "uploads" / "bot")
|
||||
YANDEX_DISK_TOKEN: str = ""
|
||||
YANDEX_DISK_ROOT: str = "/AegisOne_Service"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,142 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import (
|
||||
BotUser, BotConversation, BotMessage, BotConsentLog,
|
||||
BotCategory, BotSetting, BotFeature, BotResponseTemplate, BotKnowledgeBase
|
||||
)
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.text_renderer import render_template
|
||||
|
||||
|
||||
async def get_or_create_user(db: AsyncSession, max_user_id: int,
|
||||
first_name: str = "", last_name: str = "",
|
||||
username: str = "") -> BotUser:
|
||||
result = await db.execute(select(BotUser).where(BotUser.max_user_id == max_user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
user.last_active_at = datetime.now()
|
||||
if first_name and not user.first_name:
|
||||
user.first_name = first_name
|
||||
if last_name and not user.last_name:
|
||||
user.last_name = last_name
|
||||
if username and not user.username:
|
||||
user.username = username
|
||||
return user
|
||||
user = BotUser(
|
||||
max_user_id=max_user_id, first_name=first_name,
|
||||
last_name=last_name, username=username
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def get_open_conversation(db: AsyncSession, user_id: int) -> Optional[BotConversation]:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(
|
||||
BotConversation.user_id == user_id,
|
||||
BotConversation.status == "open"
|
||||
).order_by(BotConversation.created_at.desc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_conversation(db: AsyncSession, user_id: int) -> BotConversation:
|
||||
conv = BotConversation(user_id=user_id, status="open")
|
||||
db.add(conv)
|
||||
await db.flush()
|
||||
return conv
|
||||
|
||||
|
||||
async def add_message(db: AsyncSession, conversation_id: int, direction: str,
|
||||
text: str = "", has_attachment: bool = False,
|
||||
attachment_type: str = "", attachment_path: str = "",
|
||||
max_message_id: int = None) -> BotMessage:
|
||||
msg = BotMessage(
|
||||
conversation_id=conversation_id, direction=direction, text=text,
|
||||
has_attachment=has_attachment, attachment_type=attachment_type,
|
||||
attachment_path=attachment_path, max_message_id=max_message_id
|
||||
)
|
||||
db.add(msg)
|
||||
await db.flush()
|
||||
return msg
|
||||
|
||||
|
||||
async def log_consent(db: AsyncSession, user_id: int, action: str, method: str = "", ip: str = ""):
|
||||
log = BotConsentLog(user_id=user_id, action=action, method=method, ip_address=ip)
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def get_template(db: AsyncSession, key: str) -> str:
|
||||
await SettingsCache.refresh(db)
|
||||
return SettingsCache.get_template(key)
|
||||
|
||||
|
||||
async def get_template_rendered(db: AsyncSession, key: str, **kwargs) -> str:
|
||||
tmpl = await get_template(db, key)
|
||||
return render_template(tmpl, **kwargs)
|
||||
|
||||
|
||||
async def find_in_knowledge_base(db: AsyncSession, query: str) -> Optional[BotKnowledgeBase]:
|
||||
query_lower = query.lower()
|
||||
result = await db.execute(
|
||||
select(BotKnowledgeBase).where(BotKnowledgeBase.active == True)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
for entry in entries:
|
||||
if query_lower in entry.question.lower():
|
||||
return entry
|
||||
keywords = entry.keywords or []
|
||||
for kw in keywords:
|
||||
if kw.lower() in query_lower:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
async def auto_categorize_text(db: AsyncSession, text: str) -> Optional[BotCategory]:
|
||||
text_lower = text.lower()
|
||||
categories = SettingsCache.get_categories()
|
||||
best_match = None
|
||||
best_score = 0
|
||||
for cat in categories:
|
||||
keywords = cat.keywords or []
|
||||
score = sum(1 for kw in keywords if kw.lower() in text_lower)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = cat
|
||||
return best_match if best_score > 0 else None
|
||||
|
||||
|
||||
async def close_conversation(db: AsyncSession, conversation_id: int):
|
||||
conv = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conversation_id)
|
||||
)
|
||||
conv = conv.scalar_one_or_none()
|
||||
if conv:
|
||||
conv.status = "closed"
|
||||
conv.closed_at = datetime.now()
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def update_conversation_status(db: AsyncSession, conversation_id: int, status: str):
|
||||
conv = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conversation_id)
|
||||
)
|
||||
conv = conv.scalar_one_or_none()
|
||||
if conv:
|
||||
conv.status = status
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def detect_negative_emotion(text: str) -> bool:
|
||||
negative_words = [
|
||||
"ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо",
|
||||
"бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь",
|
||||
"раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд",
|
||||
"верните деньги", "мошенники", "обман"
|
||||
]
|
||||
text_lower = text.lower()
|
||||
return any(word in text_lower for word in negative_words)
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine_kwargs = {"echo": False}
|
||||
if "postgresql" in settings.DATABASE_URL:
|
||||
engine_kwargs["pool_size"] = 10
|
||||
engine_kwargs["max_overflow"] = 20
|
||||
engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs)
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".txt", ".jpg", ".jpeg", ".png", ".gif"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileStorage:
|
||||
def __init__(self, base_dir: str = "uploads/bot"):
|
||||
self.base_dir = Path(base_dir)
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def save_file(self, file_bytes: bytes, filename: str, conversation_id: int,
|
||||
file_type: str = "document", db=None) -> str:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
ext = ".bin"
|
||||
safe_name = f"{uuid.uuid4().hex}{ext}"
|
||||
conv_dir = self.base_dir / str(conversation_id)
|
||||
conv_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = conv_dir / safe_name
|
||||
file_path.write_bytes(file_bytes)
|
||||
|
||||
meta = {
|
||||
"type": "conversation",
|
||||
"id": conversation_id,
|
||||
"filename": filename,
|
||||
"file_type": file_type,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"local_path": str(file_path),
|
||||
}
|
||||
meta_path = conv_dir / "metadata.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
existing = json.loads(meta_path.read_text())
|
||||
if "attachments" not in existing:
|
||||
existing["attachments"] = []
|
||||
existing["attachments"].append({"filename": safe_name, "original": filename})
|
||||
meta_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
meta["attachments"] = [{"filename": safe_name, "original": filename}]
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
# Queue for Yandex Disk sync (service portal picks this up)
|
||||
if db is not None:
|
||||
try:
|
||||
from app.models.models import FileSyncQueue
|
||||
remote_path = f"bot/conversations/{conversation_id}/{safe_name}"
|
||||
queue = FileSyncQueue(
|
||||
local_path=str(file_path),
|
||||
remote_path=remote_path,
|
||||
entity_type="conversation",
|
||||
entity_id=conversation_id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(queue)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to queue file sync: {e}")
|
||||
|
||||
return str(file_path)
|
||||
|
||||
def get_file_type(self, filename: str) -> str:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in {".jpg", ".jpeg", ".png", ".gif"}:
|
||||
return "image"
|
||||
if ext in {".pdf", ".docx", ".xlsx", ".txt"}:
|
||||
return "document"
|
||||
return "other"
|
||||
|
||||
|
||||
storage = FileStorage()
|
||||
@@ -0,0 +1,209 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BotState(str, Enum):
|
||||
IDLE = "idle"
|
||||
NAME_REQUEST = "name_request"
|
||||
CONSENT_REQUEST = "consent_request"
|
||||
CONTACT_REQUEST = "contact_request"
|
||||
INQUIRY_REQUEST = "inquiry_request"
|
||||
CATEGORY_SELECT = "category_select"
|
||||
CONFIRMATION = "confirmation"
|
||||
HANDOFF_REQUEST = "handoff_request"
|
||||
FEATURE_RISK_SCORE = "feature_risk_score"
|
||||
FEATURE_SLA_STATUS = "feature_sla_status"
|
||||
FEATURE_PHOTO_REPORT = "feature_photo_report"
|
||||
FEATURE_APPOINTMENT = "feature_appointment"
|
||||
FEATURE_QUALITY_RATE = "feature_quality_rate"
|
||||
FEATURE_COMMERCIAL = "feature_commercial"
|
||||
FEATURE_MY_OBJECTS = "feature_my_objects"
|
||||
FEATURE_REMINDERS = "feature_reminders"
|
||||
SLA_TICKET_CREATE = "sla_ticket_create"
|
||||
KB_SEARCH = "kb_search"
|
||||
FEATURE_NOTIFICATIONS = "feature_notifications"
|
||||
FEATURE_BROADCAST = "feature_broadcast"
|
||||
AWAITING_RATING = "awaiting_rating"
|
||||
WAITING_1C_RESPONSE = "waiting_1c_response"
|
||||
MAIN_MENU = "main_menu"
|
||||
HANDOFF_CONFIRMATION = "handoff_confirmation"
|
||||
OUT_OF_HOURS = "out_of_hours"
|
||||
|
||||
|
||||
STATE_TRANSITIONS = {
|
||||
BotState.IDLE: {
|
||||
"start": BotState.NAME_REQUEST,
|
||||
"new_inquiry": BotState.NAME_REQUEST,
|
||||
"handoff": BotState.HANDOFF_REQUEST,
|
||||
"main_menu": BotState.MAIN_MENU,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.NAME_REQUEST: {
|
||||
"name_provided": BotState.CONSENT_REQUEST,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.CONSENT_REQUEST: {
|
||||
"consent_given": BotState.CONTACT_REQUEST,
|
||||
"consent_refused": BotState.IDLE,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.CONTACT_REQUEST: {
|
||||
"contact_provided": BotState.INQUIRY_REQUEST,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.INQUIRY_REQUEST: {
|
||||
"inquiry_provided": BotState.CATEGORY_SELECT,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.CATEGORY_SELECT: {
|
||||
"category_selected": BotState.CONFIRMATION,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.CONFIRMATION: {
|
||||
"done": BotState.IDLE,
|
||||
"edit_name": BotState.NAME_REQUEST,
|
||||
"edit_contact": BotState.CONTACT_REQUEST,
|
||||
"edit_inquiry": BotState.INQUIRY_REQUEST,
|
||||
},
|
||||
BotState.HANDOFF_REQUEST: {
|
||||
"confirmed": BotState.HANDOFF_CONFIRMATION,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.HANDOFF_CONFIRMATION: {
|
||||
"done": BotState.IDLE,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.OUT_OF_HOURS: {
|
||||
"leave_message": BotState.INQUIRY_REQUEST,
|
||||
"callback": BotState.NAME_REQUEST,
|
||||
"cancel": BotState.IDLE,
|
||||
"main_menu": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.MAIN_MENU: {
|
||||
"new_inquiry": BotState.NAME_REQUEST,
|
||||
"feature_risk_score": BotState.FEATURE_RISK_SCORE,
|
||||
"feature_sla_status": BotState.FEATURE_SLA_STATUS,
|
||||
"feature_photo_report": BotState.FEATURE_PHOTO_REPORT,
|
||||
"feature_appointment": BotState.FEATURE_APPOINTMENT,
|
||||
"feature_quality_rate": BotState.FEATURE_QUALITY_RATE,
|
||||
"feature_commercial": BotState.FEATURE_COMMERCIAL,
|
||||
"feature_my_objects": BotState.FEATURE_MY_OBJECTS,
|
||||
"feature_reminders": BotState.FEATURE_REMINDERS,
|
||||
"feature_notifications": BotState.FEATURE_NOTIFICATIONS,
|
||||
"feature_broadcast": BotState.FEATURE_BROADCAST,
|
||||
"sla_ticket_create": BotState.SLA_TICKET_CREATE,
|
||||
"handoff": BotState.HANDOFF_REQUEST,
|
||||
"kb_search": BotState.KB_SEARCH,
|
||||
"cancel": BotState.IDLE,
|
||||
},
|
||||
BotState.FEATURE_RISK_SCORE: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_SLA_STATUS: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_PHOTO_REPORT: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"photo_provided": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_APPOINTMENT: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"date_provided": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_QUALITY_RATE: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"rating_provided": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_COMMERCIAL: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_MY_OBJECTS: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_REMINDERS: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_NOTIFICATIONS: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.FEATURE_BROADCAST: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.SLA_TICKET_CREATE: {
|
||||
"done": BotState.MAIN_MENU,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.KB_SEARCH: {
|
||||
"found": BotState.MAIN_MENU,
|
||||
"not_found": BotState.INQUIRY_REQUEST,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
BotState.AWAITING_RATING: {
|
||||
"rating_provided": BotState.IDLE,
|
||||
"skip": BotState.IDLE,
|
||||
},
|
||||
BotState.WAITING_1C_RESPONSE: {
|
||||
"received": BotState.MAIN_MENU,
|
||||
"timeout": BotState.IDLE,
|
||||
"cancel": BotState.MAIN_MENU,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FSM:
|
||||
_states: dict = {}
|
||||
_tmp: dict = {}
|
||||
|
||||
@classmethod
|
||||
def get_state(cls, user_id: int) -> BotState:
|
||||
return cls._states.get(user_id, BotState.IDLE)
|
||||
|
||||
@classmethod
|
||||
def set_state(cls, user_id: int, state: BotState):
|
||||
cls._states[user_id] = state
|
||||
|
||||
@classmethod
|
||||
def reset(cls, user_id: int):
|
||||
cls._states.pop(user_id, None)
|
||||
cls._tmp.pop(user_id, None)
|
||||
|
||||
@classmethod
|
||||
def set_temp(cls, user_id: int, data: dict):
|
||||
cls._tmp[user_id] = data
|
||||
|
||||
@classmethod
|
||||
def get_temp(cls, user_id: int) -> dict:
|
||||
return cls._tmp.get(user_id, {})
|
||||
|
||||
@classmethod
|
||||
def clear_temp(cls, user_id: int):
|
||||
cls._tmp.pop(user_id, None)
|
||||
|
||||
@classmethod
|
||||
def can_transition(cls, user_id: int, event: str) -> bool:
|
||||
current = cls.get_state(user_id)
|
||||
return event in STATE_TRANSITIONS.get(current, {})
|
||||
|
||||
@classmethod
|
||||
def transition(cls, user_id: int, event: str) -> BotState:
|
||||
current = cls.get_state(user_id)
|
||||
transitions = STATE_TRANSITIONS.get(current, {})
|
||||
if event not in transitions:
|
||||
return current
|
||||
new_state = transitions[event]
|
||||
cls.set_state(user_id, new_state)
|
||||
return new_state
|
||||
|
||||
@classmethod
|
||||
def clear_all(cls):
|
||||
cls._states.clear()
|
||||
cls._tmp.clear()
|
||||
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.
@@ -0,0 +1,60 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, log_consent, get_template_rendered
|
||||
from app.keyboards.inline import build_consent_buttons, build_contact_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_consent_request(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id,
|
||||
user_data.get("first_name", ""),
|
||||
user_data.get("last_name", ""),
|
||||
user_data.get("username", ""))
|
||||
await db.flush()
|
||||
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
policy_url = SettingsCache.get("consent_policy_url", "https://aegisone.ru/politica.php")
|
||||
text = await get_template_rendered(db, "consent_request", policy_url=policy_url)
|
||||
buttons = build_consent_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.CONSENT_REQUEST)
|
||||
|
||||
|
||||
async def handle_consent_given(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
user.consent_given = True
|
||||
from datetime import datetime
|
||||
user.consent_timestamp = datetime.now()
|
||||
user.consent_method = "button"
|
||||
await log_consent(db, user.id, "given", "button", user_data.get("ip", ""))
|
||||
await db.flush()
|
||||
|
||||
text = await get_template_rendered(db, "contact_request")
|
||||
buttons = build_contact_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
|
||||
FSM.set_state(max_user_id, BotState.CONTACT_REQUEST)
|
||||
|
||||
|
||||
async def handle_consent_refused(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
await log_consent(db, user.id, "revoked", "button", user_data.get("ip", ""))
|
||||
await db.flush()
|
||||
|
||||
await SettingsCache.refresh(db)
|
||||
phone_1 = SettingsCache.get("phone_1")
|
||||
phone_2 = SettingsCache.get("phone_2")
|
||||
email = SettingsCache.get("support_email")
|
||||
|
||||
text = await get_template_rendered(db, "consent_refused",
|
||||
phone_1=phone_1, phone_2=phone_2,
|
||||
support_email=email)
|
||||
from app.keyboards.inline import build_phone_email_buttons
|
||||
subject = SettingsCache.get("email_subject")
|
||||
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject)
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.reset(max_user_id)
|
||||
@@ -0,0 +1,43 @@
|
||||
import re
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_contact_provided(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
phone = user_data.get("phone", "")
|
||||
email = user_data.get("email", "")
|
||||
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if email:
|
||||
user.email = email
|
||||
await db.flush()
|
||||
|
||||
text = await get_template_rendered(db, "inquiry_request")
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST)
|
||||
|
||||
|
||||
async def handle_contact_text(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
phone_match = re.search(r'(\+?7[\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2})', text)
|
||||
email_match = re.search(r'([\w.-]+@[\w.-]+\.\w+)', text)
|
||||
|
||||
if phone_match:
|
||||
user.phone = phone_match.group(1)
|
||||
if email_match:
|
||||
user.email = email_match.group(1)
|
||||
|
||||
if phone_match or email_match:
|
||||
await db.flush()
|
||||
await handle_contact_provided(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
await max_api.send_message(max_user_id, "Пожалуйста, укажите номер телефона или email, чтобы мы могли с вами связаться.")
|
||||
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.
@@ -0,0 +1,105 @@
|
||||
from datetime import datetime
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
_DATE_KEY = "appt_date"
|
||||
_SLOT_KEY = "appt_slot"
|
||||
|
||||
|
||||
async def handle_appointment(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
payload = user_data.get("payload", "")
|
||||
state = FSM.get_state(max_user_id)
|
||||
|
||||
if state != BotState.FEATURE_APPOINTMENT:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_APPOINTMENT)
|
||||
FSM.set_temp(max_user_id, {})
|
||||
await max_api.send_message(
|
||||
max_user_id,
|
||||
"📅 **Запись на визит**\n\nУкажите желаемую дату (ДД.ММ.ГГГГ):"
|
||||
)
|
||||
return
|
||||
|
||||
tmp = FSM.get_temp(max_user_id)
|
||||
|
||||
if _DATE_KEY not in tmp:
|
||||
try:
|
||||
parsed = datetime.strptime(text.strip(), "%d.%m.%Y")
|
||||
tmp[_DATE_KEY] = parsed.strftime("%Y-%m-%d")
|
||||
FSM.set_temp(max_user_id, tmp)
|
||||
|
||||
slots = _generate_slots()
|
||||
msg_lines = [f"📅 **{parsed.strftime('%d.%m.%Y')}**\n\nДоступные слоты:\n"]
|
||||
for i, slot in enumerate(slots, 1):
|
||||
msg_lines.append(f"{i}. {slot}")
|
||||
msg_lines.append("\nВведите номер слота (1-{}):".format(len(slots)))
|
||||
await max_api.send_message(max_user_id, "\n".join(msg_lines), format="markdown")
|
||||
except Exception:
|
||||
await max_api.send_message(max_user_id, "Неверный формат даты. Укажите дату в формате ДД.ММ.ГГГГ")
|
||||
return
|
||||
|
||||
if _SLOT_KEY not in tmp:
|
||||
slots = _generate_slots()
|
||||
try:
|
||||
idx = int(text.strip()) - 1
|
||||
if 0 <= idx < len(slots):
|
||||
tmp[_SLOT_KEY] = slots[idx]
|
||||
FSM.set_temp(max_user_id, tmp)
|
||||
await _confirm_appointment(bot, user_data, db, max_api, tmp)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
await max_api.send_message(max_user_id, f"Укажите номер слота (1-{len(slots)}):")
|
||||
return
|
||||
|
||||
await _confirm_appointment(bot, user_data, db, max_api, tmp)
|
||||
|
||||
|
||||
def _generate_slots() -> list:
|
||||
start_h = SettingsCache.get_int("work_hours_start", 9)
|
||||
end_h = SettingsCache.get_int("work_hours_end", 18)
|
||||
slots = []
|
||||
for h in range(start_h, end_h):
|
||||
slots.append(f"{h:02d}:00-{h + 1:02d}:00")
|
||||
return slots
|
||||
|
||||
|
||||
async def _confirm_appointment(bot, user_data, db, max_api, tmp):
|
||||
max_user_id = user_data["user_id"]
|
||||
date_str = tmp.get(_DATE_KEY, "—")
|
||||
slot_str = tmp.get(_SLOT_KEY, "—")
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
text = await get_template_rendered(db, "appointment_confirm",
|
||||
date=f"{date_str} {slot_str}",
|
||||
engineer="будет назначен")
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
|
||||
conv = await _create_appointment_conv(db, user.id, f"{date_str} {slot_str}")
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
await ic_integration.send_to_1c(db, conv)
|
||||
|
||||
FSM.clear_temp(max_user_id)
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
|
||||
|
||||
async def _create_appointment_conv(db, user_id, details):
|
||||
from app.conversation import create_conversation, add_message
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotCategory
|
||||
result = await db.execute(
|
||||
select(BotCategory).where(BotCategory.name == "Обслуживание").limit(1)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
conv = await create_conversation(db, user_id)
|
||||
conv.inquiry_text = f"Запись на визит: {details}"
|
||||
conv.inquiry_type = cat.id if cat else None
|
||||
conv.priority = "normal"
|
||||
await db.flush()
|
||||
await add_message(db, conv.id, "in", details)
|
||||
return conv
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_broadcast_send(bot, user_data: dict, db, max_api):
|
||||
from app.models.models import BotUser
|
||||
from sqlalchemy import select
|
||||
|
||||
text = user_data.get("text", "").strip()
|
||||
if not text:
|
||||
await max_api.send_message(
|
||||
user_data["user_id"],
|
||||
"Укажите текст рассылки в параметре broadcast_text."
|
||||
)
|
||||
return
|
||||
|
||||
result = await db.execute(select(BotUser).where(BotUser.consent_given == True))
|
||||
users = result.scalars().all()
|
||||
sent = 0
|
||||
failed = 0
|
||||
sent_ids = []
|
||||
for u in users:
|
||||
try:
|
||||
await max_api.send_message(user_id=u.max_user_id, text=text, format="markdown")
|
||||
sent += 1
|
||||
sent_ids.append(u.max_user_id)
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
from app.models.models import BotBroadcast
|
||||
bc = BotBroadcast(
|
||||
text=text,
|
||||
sent_at=datetime.now(),
|
||||
recipient_count=len(users),
|
||||
recipient_ids=sent_ids,
|
||||
status="sent",
|
||||
)
|
||||
db.add(bc)
|
||||
await db.flush()
|
||||
|
||||
result = f"📨 Рассылка завершена.\nОтправлено: {sent}\nОшибок: {failed}"
|
||||
await max_api.send_message(user_data["user_id"], result)
|
||||
@@ -0,0 +1,101 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
_STEPS = ["service_type", "scope", "address", "comment"]
|
||||
_STEP_LABELS = {
|
||||
"service_type": "Тип услуги (Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, Обслуживание, Другое)",
|
||||
"scope": "Объём работ (краткое описание)",
|
||||
"address": "Адрес объекта",
|
||||
"comment": "Дополнительные пожелания (или отправьте «-» для пропуска)",
|
||||
}
|
||||
|
||||
_COLLECT_KEY = "commercial_data"
|
||||
_STEP_KEY = "commercial_step"
|
||||
|
||||
|
||||
async def handle_commercial(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
state = FSM.get_state(max_user_id)
|
||||
|
||||
if state != BotState.FEATURE_COMMERCIAL:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_COMMERCIAL)
|
||||
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _COLLECT_KEY: {}})
|
||||
await max_api.send_message(
|
||||
max_user_id,
|
||||
f"📄 **Коммерческое предложение**\n\nШаг 1/{len(_STEPS)}:\n{_STEP_LABELS['service_type']}"
|
||||
)
|
||||
return
|
||||
|
||||
tmp = FSM.get_temp(max_user_id)
|
||||
step = tmp.get(_STEP_KEY, 0)
|
||||
data = tmp.get(_COLLECT_KEY, {})
|
||||
|
||||
if step >= len(_STEPS):
|
||||
await _finish_commercial(bot, user_data, db, max_api, data)
|
||||
return
|
||||
|
||||
step_key = _STEPS[step]
|
||||
if not text and step_key != "comment":
|
||||
await max_api.send_message(max_user_id, f"Пожалуйста, заполните поле:\n{_STEP_LABELS[step_key]}")
|
||||
return
|
||||
|
||||
data[step_key] = text if text else ""
|
||||
step += 1
|
||||
|
||||
if step < len(_STEPS):
|
||||
FSM.set_temp(max_user_id, {_STEP_KEY: step, _COLLECT_KEY: data})
|
||||
next_key = _STEPS[step]
|
||||
await max_api.send_message(
|
||||
max_user_id,
|
||||
f"📄 Шаг {step + 1}/{len(_STEPS)}:\n{_STEP_LABELS[next_key]}"
|
||||
)
|
||||
else:
|
||||
await _finish_commercial(bot, user_data, db, max_api, data)
|
||||
|
||||
|
||||
async def _finish_commercial(bot, user_data, db, max_api, data):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
summary = (
|
||||
f"📄 **Коммерческое предложение**\n\n"
|
||||
f"Услуга: {data.get('service_type', '—')}\n"
|
||||
f"Объём: {data.get('scope', '—')}\n"
|
||||
f"Адрес: {data.get('address', '—')}\n"
|
||||
f"Комментарий: {data.get('comment', '—')}\n\n"
|
||||
f"Спасибо, {user.first_name}! Мы подготовим КП и свяжемся с вами."
|
||||
)
|
||||
await max_api.send_message(max_user_id, summary, format="markdown")
|
||||
|
||||
conv_text = (
|
||||
f"Коммерческое предложение: услуга={data.get('service_type', '')}, "
|
||||
f"объём={data.get('scope', '')}, адрес={data.get('address', '')}, "
|
||||
f"комментарий={data.get('comment', '')}"
|
||||
)
|
||||
conv = await _create_commercial_conv(db, user.id, conv_text)
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
await ic_integration.send_to_1c(db, conv)
|
||||
|
||||
FSM.clear_temp(max_user_id)
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
|
||||
|
||||
async def _create_commercial_conv(db, user_id, details):
|
||||
from app.conversation import create_conversation, add_message
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotCategory
|
||||
result = await db.execute(
|
||||
select(BotCategory).where(BotCategory.name == "Консультация").limit(1)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
conv = await create_conversation(db, user_id)
|
||||
conv.inquiry_text = details
|
||||
conv.inquiry_type = cat.id if cat else None
|
||||
await db.flush()
|
||||
await add_message(db, conv.id, "in", details)
|
||||
return conv
|
||||
@@ -0,0 +1,39 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_my_objects(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_MY_OBJECTS:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_MY_OBJECTS)
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
await max_api.send_message(max_user_id,
|
||||
"🏢 **Мои объекты**\n\n"
|
||||
"Укажите название вашей компании для поиска объектов."
|
||||
)
|
||||
return
|
||||
|
||||
if not text:
|
||||
await max_api.send_message(max_user_id, "Укажите название компании.")
|
||||
return
|
||||
|
||||
from app.integrations.portal_db import get_objects_for_customer
|
||||
objects = await get_objects_for_customer(db, text)
|
||||
|
||||
if not objects:
|
||||
await max_api.send_message(max_user_id, "Объекты не найдены. Проверьте название компании.")
|
||||
else:
|
||||
lines = [f"🏢 **{obj.name}** — {obj.status}" for obj in objects[:10]]
|
||||
msg = "Ваши объекты:\n\n" + "\n".join(lines)
|
||||
if len(objects) > 10:
|
||||
msg += f"\n\n...и ещё {len(objects) - 10} объектов"
|
||||
await max_api.send_message(max_user_id, msg, format="markdown")
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,68 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_notifications(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
payload = user_data.get("payload", "")
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_NOTIFICATIONS:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_NOTIFICATIONS)
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotNotificationSubscription
|
||||
result = await db.execute(
|
||||
select(BotNotificationSubscription).where(
|
||||
BotNotificationSubscription.user_id == user.id
|
||||
).limit(1)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
status_change = "✅" if sub and sub.notify_on_status_change else "⬜"
|
||||
reply = "✅" if sub and sub.notify_on_reply else "⬜"
|
||||
broadcast = "✅" if sub and sub.notify_on_broadcast else "⬜"
|
||||
text = (
|
||||
"🔔 **Уведомления**\n\n"
|
||||
"Выберите, о чём уведомлять:\n\n"
|
||||
f"{status_change} /status — Смена статуса обращения\n"
|
||||
f"{reply} /reply — Ответ оператора\n"
|
||||
f"{broadcast} /broadcast — Новости и рассылки\n\n"
|
||||
"Нажмите на пункт, чтобы переключить."
|
||||
)
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
return
|
||||
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotNotificationSubscription
|
||||
result = await db.execute(
|
||||
select(BotNotificationSubscription).where(
|
||||
BotNotificationSubscription.user_id == user.id
|
||||
).limit(1)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
if not sub:
|
||||
sub = BotNotificationSubscription(user_id=user.id)
|
||||
db.add(sub)
|
||||
await db.flush()
|
||||
|
||||
toggle_map = {
|
||||
"/status": "notify_on_status_change",
|
||||
"/reply": "notify_on_reply",
|
||||
"/broadcast": "notify_on_broadcast",
|
||||
}
|
||||
key = payload if payload.startswith("/") else (payload if payload in toggle_map else user_data.get("text", "").strip())
|
||||
if key in toggle_map:
|
||||
col = toggle_map[key]
|
||||
current = getattr(sub, col)
|
||||
setattr(sub, col, not current)
|
||||
await db.flush()
|
||||
await max_api.send_message(max_user_id, f"Настройка «{key}» изменена.")
|
||||
else:
|
||||
await max_api.send_message(max_user_id, "Нажмите /status, /reply или /broadcast для переключения.")
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,69 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_open_conversation, create_conversation, add_message, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_photo_report(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_PHOTO_REPORT:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_PHOTO_REPORT)
|
||||
await max_api.send_message(max_user_id,
|
||||
"📸 **Фото-отчёт**\n\n"
|
||||
"Отправьте фото проблемы и опишите, что произошло.\n"
|
||||
"Вы можете прикрепить документ (PDF, DOCX, XLSX, TXT)."
|
||||
)
|
||||
return
|
||||
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
conv = await get_open_conversation(db, user.id)
|
||||
if not conv:
|
||||
conv = await create_conversation(db, user.id)
|
||||
|
||||
has_attachment = user_data.get("has_attachment", False)
|
||||
attachment_type = user_data.get("attachment_type", "")
|
||||
attachment_path = user_data.get("attachment_path", "")
|
||||
|
||||
conv.inquiry_text = text or "Фото-отчёт"
|
||||
conv.has_attachment = has_attachment
|
||||
conv.attachment_type = attachment_type
|
||||
conv.attachment_path = attachment_path
|
||||
conv.priority = "high"
|
||||
await db.flush()
|
||||
|
||||
await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path)
|
||||
|
||||
ooh = not SettingsCache.is_work_hours()
|
||||
if ooh:
|
||||
start = SettingsCache.get_int("work_hours_start", 9)
|
||||
end = SettingsCache.get_int("work_hours_end", 18)
|
||||
ooh_text = await get_template_rendered(db, "out_of_hours",
|
||||
work_hours_start=start, work_hours_end=end)
|
||||
await max_api.send_message(max_user_id, ooh_text)
|
||||
|
||||
text = await get_template_rendered(db, "confirmation_ooh" if ooh else "confirmation",
|
||||
first_name=user.first_name, conv_id=conv.id)
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
await ic_integration.send_to_1c(db, conv)
|
||||
|
||||
from app.models.models import Incident
|
||||
incident = Incident(
|
||||
object_id=0,
|
||||
reported_by=None,
|
||||
title=f"Фото-отчёт от {user.first_name or 'клиента'}",
|
||||
description=text or "Фото-отчёт через бота",
|
||||
severity="P3",
|
||||
status="open",
|
||||
)
|
||||
db.add(incident)
|
||||
await db.flush()
|
||||
conv.attachment_path = f"incident_{incident.id}"
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,35 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
|
||||
from app.keyboards.inline import build_quality_buttons, build_main_menu_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_quality_rate(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
payload = user_data.get("payload", "")
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_QUALITY_RATE:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_QUALITY_RATE)
|
||||
conv = await get_open_conversation(db, (await get_or_create_user(db, max_user_id)).id)
|
||||
conv_id = conv.id if conv else "—"
|
||||
text = await get_template_rendered(db, "quality_request", conv_id=conv_id)
|
||||
buttons = build_quality_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
|
||||
return
|
||||
|
||||
if payload.startswith("rate_"):
|
||||
try:
|
||||
rating = int(payload.replace("rate_", ""))
|
||||
except ValueError:
|
||||
return
|
||||
await max_api.send_message(max_user_id, f"Спасибо за оценку: {rating}/5! Ваш отзыв важен для нас.")
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
return
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,48 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_reminders(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_REMINDERS:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_REMINDERS)
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
phone = user.phone or ""
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from app.models.models import BotConversation, BotTicket, Customer
|
||||
|
||||
open_convs = await db.execute(
|
||||
select(func.count(BotConversation.id))
|
||||
.where(BotConversation.status.in_(["open", "handoff"]))
|
||||
)
|
||||
total_open = open_convs.scalar() or 0
|
||||
|
||||
open_tickets = await db.execute(
|
||||
select(func.count(BotTicket.id))
|
||||
.where(BotTicket.status.in_(["open", "in_progress"]))
|
||||
)
|
||||
total_tickets = open_tickets.scalar() or 0
|
||||
|
||||
my_convs = await db.execute(
|
||||
select(func.count(BotConversation.id))
|
||||
.where(BotConversation.user_id == user.id, BotConversation.status.in_(["open", "handoff"]))
|
||||
)
|
||||
my_open = my_convs.scalar() or 0
|
||||
|
||||
text = (
|
||||
"🔔 **Напоминания**\n\n"
|
||||
f"У вас открыто обращений: {my_open}\n"
|
||||
f"Всего в системе: {total_open} обращений, {total_tickets} заявок\n\n"
|
||||
"Напоминания о плановом ТО настраиваются в портале."
|
||||
)
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
return
|
||||
@@ -0,0 +1,100 @@
|
||||
from sqlalchemy import select
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
_ANSWERS_KEY = "risk_answers"
|
||||
_STEP_KEY = "risk_step"
|
||||
|
||||
|
||||
async def _load_questions(db):
|
||||
from app.models.models import BotRiskQuestion
|
||||
result = await db.execute(
|
||||
select(BotRiskQuestion)
|
||||
.where(BotRiskQuestion.is_active == True)
|
||||
.order_by(BotRiskQuestion.sort_order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def handle_risk_score(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
state = FSM.get_state(max_user_id)
|
||||
|
||||
questions = await _load_questions(db)
|
||||
if not questions:
|
||||
await max_api.send_message(max_user_id, "Анкета риск-инжиниринга временно недоступна.")
|
||||
FSM.transition(max_user_id, "cancel")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
return
|
||||
|
||||
if state != BotState.FEATURE_RISK_SCORE:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_RISK_SCORE)
|
||||
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _ANSWERS_KEY: {}})
|
||||
q = questions[0]
|
||||
await max_api.send_message(
|
||||
max_user_id,
|
||||
f"📊 **Risk Score — анкета**\n\nВопрос 1/{len(questions)}:\n{q.question}\n\nОтветьте «да» или «нет»."
|
||||
)
|
||||
return
|
||||
|
||||
tmp = FSM.get_temp(max_user_id)
|
||||
step = tmp.get(_STEP_KEY, 0)
|
||||
answers = tmp.get(_ANSWERS_KEY, {})
|
||||
|
||||
if step >= len(questions):
|
||||
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
|
||||
return
|
||||
|
||||
if text.lower() in ("да", "yes", "+", "1"):
|
||||
answers[questions[step].question_key] = True
|
||||
elif text.lower() in ("нет", "no", "-", "0"):
|
||||
answers[questions[step].question_key] = False
|
||||
else:
|
||||
await max_api.send_message(max_user_id, "Пожалуйста, ответьте «да» или «нет».")
|
||||
return
|
||||
|
||||
step += 1
|
||||
if step < len(questions):
|
||||
q = questions[step]
|
||||
FSM.set_temp(max_user_id, {_STEP_KEY: step, _ANSWERS_KEY: answers})
|
||||
await max_api.send_message(
|
||||
max_user_id,
|
||||
f"📊 Вопрос {step + 1}/{len(questions)}:\n{q.question}\n\n«да» или «нет»?"
|
||||
)
|
||||
else:
|
||||
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
|
||||
|
||||
|
||||
async def _finish_risk_score(max_user_id, answers, questions, db, max_api):
|
||||
score = 0
|
||||
for q in questions:
|
||||
if answers.get(q.question_key):
|
||||
score += q.weight
|
||||
score = min(score, 100)
|
||||
|
||||
if score < 20:
|
||||
label = "Низкий"
|
||||
rec = "Поддерживайте текущий уровень обслуживания"
|
||||
elif score < 50:
|
||||
label = "Средний"
|
||||
rec = "Рекомендуем провести аудит систем"
|
||||
elif score < 75:
|
||||
label = "Высокий"
|
||||
rec = "Необходимо срочное обслуживание"
|
||||
else:
|
||||
label = "Критический"
|
||||
rec = "Требуется немедленное вмешательство"
|
||||
|
||||
tmpl = await get_template_rendered(db, "risk_result", score=score, label=label, recommendation=rec)
|
||||
await max_api.send_message(max_user_id, tmpl, format="markdown")
|
||||
|
||||
FSM.clear_temp(max_user_id)
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,54 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_sla_status(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.FEATURE_SLA_STATUS:
|
||||
FSM.set_state(max_user_id, BotState.FEATURE_SLA_STATUS)
|
||||
await max_api.send_message(max_user_id,
|
||||
"🔍 **Проверка SLA статуса**\n\n"
|
||||
"Укажите номер или название объекта."
|
||||
)
|
||||
return
|
||||
|
||||
if not text:
|
||||
await max_api.send_message(max_user_id, "Укажите номер или название объекта.")
|
||||
return
|
||||
|
||||
from app.integrations.portal_db import get_object_by_name_or_id, get_sla_for_object, get_last_incident_for_object, get_active_tasks_for_object
|
||||
obj = await get_object_by_name_or_id(db, text)
|
||||
if not obj:
|
||||
await max_api.send_message(max_user_id, "Объект не найден.")
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
return
|
||||
|
||||
sla = await get_sla_for_object(db, obj.id)
|
||||
if not sla:
|
||||
text = await get_template_rendered(db, "sla_not_found")
|
||||
await max_api.send_message(max_user_id, text)
|
||||
else:
|
||||
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
|
||||
last_incident = await get_last_incident_for_object(db, obj.id)
|
||||
open_tasks = await get_active_tasks_for_object(db, obj.id)
|
||||
last_visit = last_incident.created_at.strftime("%d.%m.%Y") if last_incident else "—"
|
||||
tasks_count = len(open_tasks)
|
||||
text = await get_template_rendered(db, "sla_found",
|
||||
object_name=obj.name,
|
||||
service_level=level_map.get(sla.service_level, sla.service_level),
|
||||
status=sla.status,
|
||||
price=sla.sla_price_monthly or 0)
|
||||
text += f"\n📅 Последний визит: {last_visit}\n📋 Открытых задач: {tasks_count}"
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,155 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
|
||||
|
||||
async def handle_sla_ticket(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
|
||||
if FSM.get_state(max_user_id) != BotState.SLA_TICKET_CREATE:
|
||||
FSM.set_state(max_user_id, BotState.SLA_TICKET_CREATE)
|
||||
await max_api.send_message(max_user_id, "🔧 **Заявка по SLA**\n\nПроверяю ваш SLA контракт...")
|
||||
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
phone = user.phone or ""
|
||||
if not phone:
|
||||
await max_api.send_message(max_user_id, "Не могу найти ваш номер телефона. Укажите его, пожалуйста.")
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.models import Customer, Object, SLAContract
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.contact_phone.ilike(f"%{phone[-10:]}%")).limit(1)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
await _send_no_sla(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
await _send_no_sla(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(SLAContract).where(
|
||||
SLAContract.object_id == obj.id,
|
||||
SLAContract.status == "active"
|
||||
).order_by(SLAContract.created_at.desc()).limit(1)
|
||||
)
|
||||
sla = result.scalar_one_or_none()
|
||||
if not sla:
|
||||
await _send_no_sla(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
|
||||
text = await get_template_rendered(db, "sla_ticket_found",
|
||||
object_name=obj.name,
|
||||
service_level=level_map.get(sla.service_level, sla.service_level))
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
return
|
||||
|
||||
if not text:
|
||||
await max_api.send_message(max_user_id, "Опишите неисправность. Вы можете прикрепить фото или документ.")
|
||||
return
|
||||
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from app.models.models import Customer, Object, SLAContract, BotTicket
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.contact_phone.ilike(f"%{user.phone[-10:]}%")).limit(1)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
obj = None
|
||||
sla = None
|
||||
if customer:
|
||||
result = await db.execute(
|
||||
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if obj:
|
||||
result = await db.execute(
|
||||
select(SLAContract).where(
|
||||
SLAContract.object_id == obj.id, SLAContract.status == "active"
|
||||
).order_by(SLAContract.created_at.desc()).limit(1)
|
||||
)
|
||||
sla = result.scalar_one_or_none()
|
||||
|
||||
priority_map = {"start": "low", "business": "normal", "enterprise": "high"}
|
||||
response_map = {"start": "4 часа", "business": "2 часа", "enterprise": "30 минут"}
|
||||
sla_level = sla.service_level if sla else "start"
|
||||
priority = priority_map.get(sla_level, "normal")
|
||||
|
||||
result = await db.execute(select(func.count(BotTicket.id)))
|
||||
ticket_num = f"T-{result.scalar() + 1:04d}"
|
||||
|
||||
from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus
|
||||
ticket = BotTicket(
|
||||
ticket_number=ticket_num,
|
||||
user_id=user.id,
|
||||
object_id=obj.id if obj else None,
|
||||
customer_id=customer.id if customer else None,
|
||||
title=text[:100],
|
||||
description=text,
|
||||
priority=priority,
|
||||
status="open",
|
||||
created_by="bot",
|
||||
sla_contract_id=sla.id if sla else None,
|
||||
has_attachment=user_data.get("has_attachment", False),
|
||||
attachment_type=user_data.get("attachment_type", ""),
|
||||
attachment_path=user_data.get("attachment_path", ""),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
db.add(BotTicketMessage(ticket_id=ticket.id, direction="in", text=text, sender="client"))
|
||||
db.add(BotTicketStatus(ticket_id=ticket.id, old_status="", new_status="open", changed_by="bot"))
|
||||
await db.flush()
|
||||
|
||||
has_attachment = user_data.get("has_attachment", False)
|
||||
attachment_type = user_data.get("attachment_type", "")
|
||||
attachment_path = user_data.get("attachment_path", "")
|
||||
if has_attachment:
|
||||
ticket.has_attachment = True
|
||||
ticket.attachment_type = attachment_type
|
||||
ticket.attachment_path = attachment_path
|
||||
await db.flush()
|
||||
|
||||
text = await get_template_rendered(db, "sla_ticket_created",
|
||||
ticket_number=ticket_num,
|
||||
priority=priority,
|
||||
response_time=response_map.get(sla_level, "2 часа"))
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
|
||||
from app.max_notifications import notify_ticket_created as nt
|
||||
await nt(db, ticket, max_api)
|
||||
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
|
||||
|
||||
async def _send_no_sla(bot, user_data, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
await SettingsCache.refresh(db)
|
||||
phone_1 = SettingsCache.get("phone_1")
|
||||
phone_2 = SettingsCache.get("phone_2")
|
||||
email = SettingsCache.get("support_email")
|
||||
text = await get_template_rendered(db, "sla_ticket_not_found",
|
||||
phone_1=phone_1, phone_2=phone_2,
|
||||
support_email=email)
|
||||
from app.keyboards.inline import build_phone_email_buttons
|
||||
subject = SettingsCache.get("email_subject")
|
||||
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject)
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.transition(max_user_id, "done")
|
||||
buttons = build_main_menu_buttons()
|
||||
menu_text = await get_template_rendered(db, "main_menu")
|
||||
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
|
||||
@@ -0,0 +1,33 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_start(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
first_name = user_data.get("first_name", "")
|
||||
last_name = user_data.get("last_name", "")
|
||||
username = user_data.get("username", "")
|
||||
|
||||
user = await get_or_create_user(db, max_user_id, first_name, last_name, username)
|
||||
await db.flush()
|
||||
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
open_conv = await get_open_conversation(db, user.id)
|
||||
name = SettingsCache.get_assistant_name()
|
||||
|
||||
if open_conv:
|
||||
text = await get_template_rendered(db, "greeting_returning",
|
||||
first_name=user.first_name or first_name,
|
||||
conv_id=open_conv.id)
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.IDLE)
|
||||
return
|
||||
|
||||
text = await get_template_rendered(db, "greeting", name=name)
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.IDLE)
|
||||
@@ -0,0 +1,32 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_handoff_request(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
if not SettingsCache.is_work_hours():
|
||||
from app.handlers.out_of_hours import handle_out_of_hours
|
||||
await handle_out_of_hours(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
text = await get_template_rendered(db, "handoff")
|
||||
await max_api.send_message(max_user_id, text)
|
||||
|
||||
conv = await get_open_conversation(db, user.id)
|
||||
if conv:
|
||||
conv.status = "handoff"
|
||||
conv.priority = "high"
|
||||
await db.flush()
|
||||
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
await ic_integration.send_to_1c(db, conv)
|
||||
|
||||
FSM.set_state(max_user_id, BotState.HANDOFF_REQUEST)
|
||||
|
||||
|
||||
async def handle_inquiry_fallback(bot, user_data, db, max_api):
|
||||
from app.handlers.inquiry import handle_inquiry
|
||||
await handle_inquiry(bot, user_data, db, max_api)
|
||||
@@ -0,0 +1,122 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import (
|
||||
get_or_create_user, get_open_conversation, create_conversation,
|
||||
add_message, auto_categorize_text, get_template_rendered
|
||||
)
|
||||
from app.integrations.yandex_gpt import categorize_with_gpt
|
||||
from app.keyboards.inline import build_category_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_inquiry(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
text = user_data.get("text", "").strip()
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
if not text:
|
||||
await max_api.send_message(max_user_id, "Пожалуйста, опишите ваш вопрос.")
|
||||
return
|
||||
|
||||
conv = await get_open_conversation(db, user.id)
|
||||
if not conv:
|
||||
conv = await create_conversation(db, user.id)
|
||||
|
||||
conv.inquiry_text = text
|
||||
|
||||
has_attachment = user_data.get("has_attachment", False)
|
||||
attachment_type = user_data.get("attachment_type", "")
|
||||
attachment_path_str = user_data.get("attachment_path", "")
|
||||
if has_attachment:
|
||||
conv.has_attachment = True
|
||||
conv.attachment_type = attachment_type
|
||||
conv.attachment_path = attachment_path_str
|
||||
|
||||
await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path_str)
|
||||
|
||||
if has_attachment and attachment_path_str and max_api:
|
||||
try:
|
||||
file_url = await max_api.get_file_url(attachment_path_str)
|
||||
if file_url:
|
||||
import os
|
||||
import aiohttp
|
||||
from app.config import get_settings
|
||||
from app.models.models import FileSyncQueue
|
||||
settings = get_settings()
|
||||
local_dir = settings.UPLOADS_DIR
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
local_filename = f"conv_{conv.id}_{int(datetime.now().timestamp())}_{attachment_type}"
|
||||
local_path = os.path.join(local_dir, local_filename)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(file_url) as resp:
|
||||
if resp.status == 200:
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
sync_entry = FileSyncQueue(
|
||||
local_path=local_path,
|
||||
remote_path=f"/AegisOne_Service/bot/{local_filename}",
|
||||
entity_type="conversation",
|
||||
entity_id=conv.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(sync_entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
gpt_key = SettingsCache.get("yandex_gpt_key")
|
||||
if gpt_key:
|
||||
cat_name = await categorize_with_gpt(text)
|
||||
if cat_name:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotCategory
|
||||
result = await db.execute(
|
||||
select(BotCategory).where(
|
||||
BotCategory.name.ilike(f"%{cat_name}%"),
|
||||
BotCategory.active == True
|
||||
).limit(1)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
if cat:
|
||||
conv.inquiry_type = cat.id
|
||||
await db.flush()
|
||||
confirm_text = await get_template_rendered(db, "auto_categorize_confirm", category=cat.name)
|
||||
from app.keyboards.inline import build_category_buttons
|
||||
buttons = build_category_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, confirm_text, buttons)
|
||||
FSM.set_state(max_user_id, BotState.CATEGORY_SELECT)
|
||||
return
|
||||
else:
|
||||
cat = await auto_categorize_text(db, text)
|
||||
if cat:
|
||||
conv.inquiry_type = cat.id
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
|
||||
ooh = not SettingsCache.is_work_hours()
|
||||
if ooh:
|
||||
start = SettingsCache.get_int("work_hours_start", 9)
|
||||
end = SettingsCache.get_int("work_hours_end", 18)
|
||||
ooh_text = await get_template_rendered(db, "out_of_hours",
|
||||
work_hours_start=start, work_hours_end=end)
|
||||
await max_api.send_message(max_user_id, ooh_text)
|
||||
|
||||
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
|
||||
|
||||
|
||||
async def _confirm_inquiry(bot, user_data, db, max_api, conv, is_ooh: bool):
|
||||
max_user_id = user_data["user_id"]
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
|
||||
if is_ooh:
|
||||
text = await get_template_rendered(db, "confirmation_ooh",
|
||||
first_name=user.first_name, conv_id=conv.id)
|
||||
else:
|
||||
text = await get_template_rendered(db, "confirmation",
|
||||
first_name=user.first_name, conv_id=conv.id)
|
||||
|
||||
await max_api.send_message(max_user_id, text, format="markdown")
|
||||
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
await ic_integration.send_to_1c(db, conv)
|
||||
|
||||
FSM.set_state(max_user_id, BotState.CONFIRMATION)
|
||||
@@ -0,0 +1,103 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_template_rendered
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
|
||||
|
||||
async def handle_main_menu(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
payload = user_data.get("payload", "")
|
||||
|
||||
if payload == "new_inquiry":
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
if user.consent_given:
|
||||
from app.handlers.contact import handle_contact_provided
|
||||
await handle_contact_provided(bot, user_data, db, max_api)
|
||||
else:
|
||||
from app.handlers.consent import handle_consent_request
|
||||
await handle_consent_request(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if payload == "handoff":
|
||||
from app.handlers.handoff import handle_handoff_request
|
||||
await handle_handoff_request(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if payload == "kb_search":
|
||||
FSM.set_state(max_user_id, BotState.KB_SEARCH)
|
||||
await max_api.send_message(max_user_id, "Задайте ваш вопрос, и я постараюсь найти ответ.")
|
||||
return
|
||||
|
||||
if payload.startswith("feature_"):
|
||||
feature_key = payload.replace("feature_", "")
|
||||
if SettingsCache.is_feature_enabled(feature_key):
|
||||
await _handle_feature(bot, user_data, db, max_api, feature_key)
|
||||
else:
|
||||
await max_api.send_message(max_user_id, "Этот функционал временно недоступен.")
|
||||
return
|
||||
|
||||
if payload.startswith("cat_"):
|
||||
await _handle_category_select(bot, user_data, db, max_api, payload)
|
||||
return
|
||||
|
||||
if payload == "cancel":
|
||||
FSM.reset(max_user_id)
|
||||
text = await get_template_rendered(db, "main_menu")
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
|
||||
return
|
||||
|
||||
FSM.reset(max_user_id)
|
||||
text = await get_template_rendered(db, "main_menu")
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
|
||||
|
||||
|
||||
async def _handle_feature(bot, user_data, db, max_api, feature_key: str):
|
||||
feature_handlers = {
|
||||
"risk_score": ("app.handlers.features.risk_score", "handle_risk_score"),
|
||||
"sla_status": ("app.handlers.features.sla_status", "handle_sla_status"),
|
||||
"photo_report": ("app.handlers.features.photo_report", "handle_photo_report"),
|
||||
"appointment": ("app.handlers.features.appointment", "handle_appointment"),
|
||||
"quality_rate": ("app.handlers.features.quality_rate", "handle_quality_rate"),
|
||||
"commercial_offer": ("app.handlers.features.commercial", "handle_commercial"),
|
||||
"my_objects": ("app.handlers.features.my_objects", "handle_my_objects"),
|
||||
"reminders": ("app.handlers.features.reminders", "handle_reminders"),
|
||||
"sla_ticket": ("app.handlers.features.sla_ticket", "handle_sla_ticket"),
|
||||
"notifications": ("app.handlers.features.notifications", "handle_notifications"),
|
||||
"broadcasts": ("app.handlers.features.broadcasts", "handle_broadcast_send"),
|
||||
}
|
||||
handler = feature_handlers.get(feature_key)
|
||||
if handler:
|
||||
import importlib
|
||||
mod = importlib.import_module(handler[0])
|
||||
func = getattr(mod, handler[1])
|
||||
await func(bot, user_data, db, max_api)
|
||||
else:
|
||||
await max_api.send_message(max_user_id, "Функция в разработке.")
|
||||
|
||||
|
||||
async def _handle_category_select(bot, user_data, db, max_api, payload: str):
|
||||
max_user_id = user_data["user_id"]
|
||||
try:
|
||||
cat_id = int(payload.replace("cat_", ""))
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BotCategory
|
||||
result = await db.execute(select(BotCategory).where(BotCategory.id == cat_id))
|
||||
cat = result.scalar_one_or_none()
|
||||
if not cat:
|
||||
return
|
||||
|
||||
from app.conversation import get_open_conversation, get_or_create_user
|
||||
user = await get_or_create_user(db, max_user_id)
|
||||
conv = await get_open_conversation(db, user.id)
|
||||
if conv:
|
||||
conv.inquiry_type = cat.id
|
||||
await db.flush()
|
||||
|
||||
ooh = not SettingsCache.is_work_hours()
|
||||
from app.handlers.inquiry import _confirm_inquiry
|
||||
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
|
||||
@@ -0,0 +1,43 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_template_rendered
|
||||
from app.keyboards.inline import build_cancel_button
|
||||
from app.fsm import BotState, FSM
|
||||
from app.handlers.main_menu import handle_main_menu
|
||||
|
||||
|
||||
async def handle_out_of_hours(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
start = SettingsCache.get_int("work_hours_start", 9)
|
||||
end = SettingsCache.get_int("work_hours_end", 18)
|
||||
hours_str = SettingsCache.get_work_hours_str()
|
||||
|
||||
text = (
|
||||
f"Сейчас мы не работаем. Наше рабочее время: Пн-Пт {hours_str} (МСК).\n\n"
|
||||
f"Оставьте сообщение — мы ответим как можно быстрее."
|
||||
)
|
||||
buttons = [
|
||||
[{"type": "message", "text": "✉️ Оставить сообщение", "payload": "leave_message"}],
|
||||
[{"type": "message", "text": "📞 Заказать звонок", "payload": "callback"}],
|
||||
]
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
|
||||
FSM.set_state(max_user_id, BotState.OUT_OF_HOURS)
|
||||
|
||||
|
||||
async def handle_out_of_hours_callback(bot, user_data: dict, db, max_api, payload: str):
|
||||
max_user_id = user_data["user_id"]
|
||||
|
||||
if payload == "leave_message":
|
||||
FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST)
|
||||
from app.handlers.inquiry import handle_inquiry
|
||||
await handle_inquiry(bot, user_data, db, max_api)
|
||||
return
|
||||
|
||||
if payload == "callback":
|
||||
from app.handlers.consent import handle_consent_request
|
||||
FSM.set_state(max_user_id, BotState.NAME_REQUEST)
|
||||
await max_api.send_message(max_user_id, "Как к вам обращаться?")
|
||||
return
|
||||
|
||||
await handle_main_menu(bot, user_data, db, max_api)
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import aiohttp
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import BotConversation, Bot1CSyncLog, BotSetting
|
||||
from app.settings_cache import SettingsCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_to_1c(db: AsyncSession, conversation: BotConversation) -> Optional[dict]:
|
||||
webhook_url = SettingsCache.get("1c_webhook_url")
|
||||
if not webhook_url:
|
||||
return None
|
||||
|
||||
user = conversation.user
|
||||
category = conversation.category
|
||||
|
||||
payload = {
|
||||
"conversation_id": conversation.id,
|
||||
"user": {
|
||||
"max_user_id": user.max_user_id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
"email": user.email,
|
||||
},
|
||||
"inquiry": {
|
||||
"text": conversation.inquiry_text,
|
||||
"category": category.name if category else "",
|
||||
"priority": conversation.priority,
|
||||
"has_attachment": conversation.has_attachment,
|
||||
"attachment_type": conversation.attachment_type,
|
||||
},
|
||||
"created_at": conversation.created_at.isoformat() if conversation.created_at else "",
|
||||
}
|
||||
|
||||
sync_log = Bot1CSyncLog(
|
||||
conversation_id=conversation.id,
|
||||
status="pending",
|
||||
request_payload=json.dumps(payload, ensure_ascii=False),
|
||||
)
|
||||
db.add(sync_log)
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
response_text = await resp.text()
|
||||
sync_log.status = "sent" if resp.status == 200 else "error"
|
||||
sync_log.response_payload = response_text
|
||||
sync_log.sent_at = datetime.now()
|
||||
await db.flush()
|
||||
if resp.status == 200:
|
||||
try:
|
||||
return await resp.json()
|
||||
except Exception:
|
||||
return {"raw": response_text}
|
||||
logger.warning(f"1C webhook returned {resp.status}: {response_text[:500]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"1C webhook error: {e}")
|
||||
sync_log.status = "error"
|
||||
sync_log.response_payload = str(e)
|
||||
sync_log.sent_at = datetime.now()
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
async def handle_1c_response(db: AsyncSession, data: dict) -> bool:
|
||||
conversation_id = data.get("conversation_id")
|
||||
if not conversation_id:
|
||||
return False
|
||||
|
||||
result = await db.execute(select(BotConversation).where(BotConversation.id == int(conversation_id)))
|
||||
conv = result.scalar_one_or_none()
|
||||
if not conv:
|
||||
return False
|
||||
|
||||
sync_log = Bot1CSyncLog(
|
||||
conversation_id=conv.id,
|
||||
status="confirmed",
|
||||
response_payload=json.dumps(data, ensure_ascii=False),
|
||||
confirmed_at=datetime.now(),
|
||||
)
|
||||
db.add(sync_log)
|
||||
|
||||
status = data.get("status", "")
|
||||
if status:
|
||||
conv.status = status
|
||||
|
||||
assigned = data.get("assigned_to", "")
|
||||
if assigned:
|
||||
conv.assigned_to = assigned
|
||||
|
||||
await db.flush()
|
||||
return True
|
||||
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.
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import Object, SLAContract, Task, Incident, Customer
|
||||
|
||||
|
||||
async def get_object_by_name_or_id(db: AsyncSession, query: str) -> Object:
|
||||
try:
|
||||
obj_id = int(query)
|
||||
return await db.get(Object, obj_id)
|
||||
except (ValueError, TypeError):
|
||||
result = await db.execute(
|
||||
select(Object).where(Object.name.ilike(f"%{query}%")).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_sla_for_object(db: AsyncSession, object_id: int) -> SLAContract:
|
||||
result = await db.execute(
|
||||
select(SLAContract).where(
|
||||
SLAContract.object_id == object_id,
|
||||
SLAContract.status == "active"
|
||||
).order_by(SLAContract.created_at.desc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_objects_for_customer(db: AsyncSession, customer_name: str) -> list:
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.name.ilike(f"%{customer_name}%")).limit(1)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(Object).where(Object.customer_id == customer.id, Object.status == "active")
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_active_tasks_for_object(db: AsyncSession, object_id: int) -> list:
|
||||
result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.object_id == object_id,
|
||||
Task.status.in_(["open", "in_progress"])
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_last_incident_for_object(db: AsyncSession, object_id: int):
|
||||
result = await db.execute(
|
||||
select(Incident).where(
|
||||
Incident.object_id == object_id,
|
||||
Incident.status.in_(["resolved", "closed"])
|
||||
).order_by(Incident.created_at.desc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,80 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
from app.settings_cache import SettingsCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def categorize_with_gpt(text: str) -> str:
|
||||
gpt_key = SettingsCache.get("yandex_gpt_key")
|
||||
folder_id = SettingsCache.get("yandex_folder_id")
|
||||
if not gpt_key or not folder_id:
|
||||
return ""
|
||||
|
||||
prompt = (
|
||||
"Определи категорию обращения клиента из списка: "
|
||||
"Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, "
|
||||
"Обслуживание, Консультация, Другое. "
|
||||
"Ответь только названием категории, без пояснений. "
|
||||
f"Текст обращения: {text}"
|
||||
)
|
||||
|
||||
url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
||||
headers = {
|
||||
"Authorization": f"Api-Key {gpt_key}",
|
||||
"Content-Type": "application/json",
|
||||
"x-folder-id": folder_id,
|
||||
}
|
||||
body = {
|
||||
"modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest",
|
||||
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 50},
|
||||
"messages": [{"role": "user", "text": prompt}],
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=body) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip()
|
||||
logger.warning(f"GPT categorize returned {resp.status}")
|
||||
except Exception as e:
|
||||
logger.error(f"GPT categorize error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def analyze_emotion_with_gpt(text: str) -> str:
|
||||
gpt_key = SettingsCache.get("yandex_gpt_key")
|
||||
folder_id = SettingsCache.get("yandex_folder_id")
|
||||
if not gpt_key or not folder_id:
|
||||
return "neutral"
|
||||
|
||||
prompt = (
|
||||
"Определи эмоциональную окраску текста клиента. "
|
||||
"Варианты: positive, neutral, negative. "
|
||||
"Ответь только одним словом. "
|
||||
f"Текст: {text}"
|
||||
)
|
||||
|
||||
url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
||||
headers = {
|
||||
"Authorization": f"Api-Key {gpt_key}",
|
||||
"Content-Type": "application/json",
|
||||
"x-folder-id": folder_id,
|
||||
}
|
||||
body = {
|
||||
"modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest",
|
||||
"completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 10},
|
||||
"messages": [{"role": "user", "text": prompt}],
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=body) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip().lower()
|
||||
logger.warning(f"GPT emotion analysis returned {resp.status}")
|
||||
except Exception as e:
|
||||
logger.error(f"GPT emotion analysis error: {e}")
|
||||
return "neutral"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
|
||||
|
||||
def build_main_menu_buttons() -> list:
|
||||
buttons = []
|
||||
row = [
|
||||
{"type": "message", "text": "📋 Оставить заявку", "payload": "new_inquiry"},
|
||||
{"type": "message", "text": "📞 Связаться с оператором", "payload": "handoff"},
|
||||
]
|
||||
buttons.append(row)
|
||||
|
||||
features = SettingsCache.get_enabled_features()
|
||||
feature_buttons = []
|
||||
feature_map = {
|
||||
"risk_score": ("📊 Risk Score", "feature_risk_score"),
|
||||
"sla_status": ("🔍 SLA статус", "feature_sla_status"),
|
||||
"photo_report": ("📸 Фото-отчёт", "feature_photo_report"),
|
||||
"appointment": ("📅 Запись на визит", "feature_appointment"),
|
||||
"quality_rate": ("⭐ Оценка качества", "feature_quality_rate"),
|
||||
"commercial_offer": ("📄 Коммерческое предложение", "feature_commercial"),
|
||||
"my_objects": ("🏢 Мои объекты", "feature_my_objects"),
|
||||
"reminders": ("🔔 Напоминания", "feature_reminders"),
|
||||
"notifications": ("🔔 Уведомления", "feature_notifications"),
|
||||
"broadcasts": ("📢 Рассылки", "feature_broadcast"),
|
||||
}
|
||||
for key, (label, payload) in feature_map.items():
|
||||
if key in features:
|
||||
feature_buttons.append({"type": "message", "text": label, "payload": payload})
|
||||
|
||||
while feature_buttons:
|
||||
buttons.append(feature_buttons[:2])
|
||||
feature_buttons = feature_buttons[2:]
|
||||
|
||||
row = [{"type": "message", "text": "❓ Задать вопрос", "payload": "kb_search"}]
|
||||
buttons.append(row)
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
def build_consent_buttons() -> list:
|
||||
return [[
|
||||
{"type": "message", "text": "✅ Даю согласие", "payload": "consent_given"},
|
||||
], [
|
||||
{"type": "message", "text": "❌ Не даю согласие", "payload": "consent_refused"},
|
||||
]]
|
||||
|
||||
|
||||
def build_contact_buttons() -> list:
|
||||
return [[
|
||||
{"type": "request_contact", "text": "📱 Поделиться контактом"},
|
||||
]]
|
||||
|
||||
|
||||
def build_category_buttons() -> list:
|
||||
categories = SettingsCache.get_categories()
|
||||
buttons = []
|
||||
row = []
|
||||
for cat in categories:
|
||||
row.append({"type": "message", "text": f"{cat.icon_emoji} {cat.name}", "payload": f"cat_{cat.id}"})
|
||||
if len(row) >= 2:
|
||||
buttons.append(row)
|
||||
row = []
|
||||
if row:
|
||||
buttons.append(row)
|
||||
return buttons
|
||||
|
||||
|
||||
def build_quality_buttons() -> list:
|
||||
return [[
|
||||
{"type": "callback", "text": "⭐", "payload": "rate_1"},
|
||||
{"type": "callback", "text": "⭐⭐", "payload": "rate_2"},
|
||||
{"type": "callback", "text": "⭐⭐⭐", "payload": "rate_3"},
|
||||
], [
|
||||
{"type": "callback", "text": "⭐⭐⭐⭐", "payload": "rate_4"},
|
||||
{"type": "callback", "text": "⭐⭐⭐⭐⭐", "payload": "rate_5"},
|
||||
]]
|
||||
|
||||
|
||||
def build_cancel_button() -> list:
|
||||
return [[{"type": "message", "text": "↩️ Отмена", "payload": "cancel"}]]
|
||||
|
||||
|
||||
def build_phone_email_buttons(phone_1: str, phone_2: str, email: str, subject: str) -> list:
|
||||
from urllib.parse import quote
|
||||
buttons = [
|
||||
[{"type": "link", "text": f"📞 {phone_1}", "url": f"tel:{phone_1.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}],
|
||||
[{"type": "link", "text": f"📞 {phone_2}", "url": f"tel:{phone_2.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}],
|
||||
[{"type": "link", "text": f"✉️ {email}", "url": f"mailto:{email}?subject={quote(subject)}"}],
|
||||
]
|
||||
return buttons
|
||||
@@ -0,0 +1,879 @@
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request, Depends, Query
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db, async_session, engine
|
||||
from app.models.models import Base
|
||||
from app.max_api import MaxAPIClient
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.bot_engine import handle_update
|
||||
from app.text_renderer import render_template, make_clickable_links
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
max_api: Optional[MaxAPIClient] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global max_api
|
||||
async with engine.begin() as conn:
|
||||
pass
|
||||
|
||||
async with async_session() as db:
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
token = SettingsCache.get_bot_token()
|
||||
if token:
|
||||
max_api = MaxAPIClient(token)
|
||||
try:
|
||||
me = await max_api.get_me()
|
||||
logger.info(f"Bot connected: {me}")
|
||||
except Exception as e:
|
||||
logger.error(f"Bot connection failed: {e}")
|
||||
else:
|
||||
logger.warning("No bot token configured")
|
||||
|
||||
yield
|
||||
|
||||
if max_api:
|
||||
await max_api.close()
|
||||
|
||||
|
||||
app = FastAPI(title="AegisOne Max Bot — София", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.post(settings.WEBHOOK_PATH)
|
||||
async def webhook(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
secret_header = request.headers.get("X-Max-Bot-Api-Secret", "")
|
||||
webhook_secret = settings.WEBHOOK_SECRET
|
||||
if webhook_secret and secret_header != webhook_secret:
|
||||
logger.warning("Invalid webhook secret")
|
||||
return JSONResponse({"error": "Invalid secret"}, status_code=403)
|
||||
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON from MAX: {e}, raw={raw[:500]}")
|
||||
return JSONResponse({"ok": False}, status_code=200)
|
||||
|
||||
logger.info(f"Webhook update: {body.get('update_type', 'unknown')}")
|
||||
|
||||
global max_api
|
||||
if not max_api:
|
||||
token = SettingsCache.get_bot_token()
|
||||
if token:
|
||||
max_api = MaxAPIClient(token)
|
||||
|
||||
try:
|
||||
await handle_update(None, body, db, max_api)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook handler error: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/1c/response")
|
||||
@app.post("/api/1c/response")
|
||||
async def api_1c_response(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
body = await request.json()
|
||||
from app.integrations import _1c_unf as ic_integration
|
||||
success = await ic_integration.handle_1c_response(db, body)
|
||||
await db.commit()
|
||||
if success:
|
||||
conv_id = body.get("conversation_id")
|
||||
user_id = body.get("user_id")
|
||||
if user_id and max_api:
|
||||
status = body.get("status", "в работе")
|
||||
engineer = body.get("engineer", "")
|
||||
from app.conversation import get_template_rendered
|
||||
text = await get_template_rendered(db, "status_update",
|
||||
conv_id=conv_id, status=status, engineer=engineer)
|
||||
await max_api.send_message(user_id, text, format="markdown")
|
||||
return JSONResponse({"ok": success})
|
||||
except Exception as e:
|
||||
logger.error(f"1C response error: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "bot": "София"}
|
||||
|
||||
|
||||
# ===================== Service Portal API =====================
|
||||
|
||||
@app.get("/api/bot/settings")
|
||||
async def api_bot_settings(db: AsyncSession = Depends(get_db)):
|
||||
await SettingsCache.refresh(db)
|
||||
return JSONResponse(SettingsCache.get_all())
|
||||
|
||||
|
||||
@app.post("/api/bot/settings/save")
|
||||
async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotSetting
|
||||
body = await request.json()
|
||||
for key, value in body.items():
|
||||
result = await db.execute(select(BotSetting).where(BotSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = str(value)
|
||||
else:
|
||||
db.add(BotSetting(key=key, value=str(value)))
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/templates")
|
||||
async def api_bot_templates(db: AsyncSession = Depends(get_db)):
|
||||
await SettingsCache.refresh(db)
|
||||
return JSONResponse(SettingsCache.get_all_templates())
|
||||
|
||||
|
||||
@app.post("/api/bot/templates/save")
|
||||
async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotResponseTemplate
|
||||
body = await request.json()
|
||||
for key, value in body.items():
|
||||
result = await db.execute(select(BotResponseTemplate).where(BotResponseTemplate.template_key == key))
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if tmpl:
|
||||
tmpl.template_text = value
|
||||
else:
|
||||
db.add(BotResponseTemplate(template_key=key, template_text=value))
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/features")
|
||||
async def api_bot_features(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotFeature
|
||||
result = await db.execute(select(BotFeature).order_by(BotFeature.sort_order))
|
||||
features = result.scalars().all()
|
||||
return JSONResponse([
|
||||
{"key": f.feature_key, "name": f.name, "description": f.description, "enabled": f.enabled}
|
||||
for f in features
|
||||
])
|
||||
|
||||
|
||||
@app.post("/api/bot/features/toggle")
|
||||
async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotFeature
|
||||
body = await request.json()
|
||||
key = body.get("feature_key")
|
||||
enabled = body.get("enabled", False)
|
||||
result = await db.execute(select(BotFeature).where(BotFeature.feature_key == key))
|
||||
feature = result.scalar_one_or_none()
|
||||
if feature:
|
||||
feature.enabled = enabled
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/categories")
|
||||
async def api_bot_categories(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotCategory
|
||||
result = await db.execute(select(BotCategory).order_by(BotCategory.sort_order))
|
||||
cats = result.scalars().all()
|
||||
return JSONResponse([
|
||||
{"id": c.id, "name": c.name, "description": c.description, "active": c.active,
|
||||
"icon_emoji": c.icon_emoji, "sort_order": c.sort_order, "keywords": c.keywords}
|
||||
for c in cats
|
||||
])
|
||||
|
||||
|
||||
@app.post("/api/bot/categories/create")
|
||||
async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotCategory
|
||||
body = await request.json()
|
||||
db.add(BotCategory(
|
||||
name=body.get("name", ""), description=body.get("description", ""),
|
||||
active=body.get("active", True), icon_emoji=body.get("icon_emoji", "📋"),
|
||||
sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []),
|
||||
))
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/categories/edit")
|
||||
async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotCategory
|
||||
body = await request.json()
|
||||
result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id")))
|
||||
cat = result.scalar_one_or_none()
|
||||
if cat:
|
||||
for f in ("name", "description", "active", "icon_emoji", "sort_order", "keywords"):
|
||||
if f in body:
|
||||
setattr(cat, f, body[f])
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/categories/delete")
|
||||
async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotCategory
|
||||
body = await request.json()
|
||||
result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id")))
|
||||
cat = result.scalar_one_or_none()
|
||||
if cat:
|
||||
await db.delete(cat)
|
||||
await db.commit()
|
||||
SettingsCache._last_update = 0
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/kb")
|
||||
async def api_bot_kb(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotKnowledgeBase
|
||||
result = await db.execute(select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order))
|
||||
entries = result.scalars().all()
|
||||
return JSONResponse([
|
||||
{"id": e.id, "question": e.question, "answer": e.answer, "category": e.category,
|
||||
"active": e.active, "sort_order": e.sort_order, "keywords": e.keywords}
|
||||
for e in entries
|
||||
])
|
||||
|
||||
|
||||
@app.post("/api/bot/kb/create")
|
||||
async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotKnowledgeBase
|
||||
body = await request.json()
|
||||
db.add(BotKnowledgeBase(
|
||||
question=body.get("question", ""), answer=body.get("answer", ""),
|
||||
category=body.get("category", ""), active=body.get("active", True),
|
||||
sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []),
|
||||
))
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/kb/edit")
|
||||
async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotKnowledgeBase
|
||||
body = await request.json()
|
||||
result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id")))
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry:
|
||||
for f in ("question", "answer", "category", "active", "sort_order", "keywords"):
|
||||
if f in body:
|
||||
setattr(entry, f, body[f])
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/kb/delete")
|
||||
async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotKnowledgeBase
|
||||
body = await request.json()
|
||||
result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id")))
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry:
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/conversations")
|
||||
async def api_bot_conversations(db: AsyncSession = Depends(get_db),
|
||||
status: str = Query(None),
|
||||
page: int = Query(1),
|
||||
limit: int = Query(20)):
|
||||
from app.models.models import BotConversation, BotUser
|
||||
query = select(BotConversation, BotUser.first_name).join(BotUser)
|
||||
if status:
|
||||
query = query.where(BotConversation.status == status)
|
||||
query = query.order_by(BotConversation.created_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
return JSONResponse([
|
||||
{"id": c.id, "user_name": fn, "status": c.status, "inquiry_text": c.inquiry_text[:100],
|
||||
"created_at": c.created_at.isoformat() if c.created_at else "", "priority": c.priority}
|
||||
for c, fn in rows
|
||||
])
|
||||
|
||||
|
||||
@app.get("/api/bot/users")
|
||||
async def api_bot_users(db: AsyncSession = Depends(get_db),
|
||||
page: int = Query(1), limit: int = Query(20),
|
||||
sort_by: str = Query("created_at"),
|
||||
sort_dir: str = Query("desc"),
|
||||
consent: str = Query(None)):
|
||||
from app.models.models import BotUser
|
||||
allowed_sort = {"id", "max_user_id", "first_name", "last_name", "phone", "email", "consent_given", "created_at"}
|
||||
if sort_by not in allowed_sort:
|
||||
sort_by = "created_at"
|
||||
sort_col = getattr(BotUser, sort_by)
|
||||
order = sort_col.desc() if sort_dir == "desc" else sort_col.asc()
|
||||
|
||||
filters = []
|
||||
if consent == "true":
|
||||
filters.append(BotUser.consent_given == True)
|
||||
elif consent == "false":
|
||||
filters.append(BotUser.consent_given == False)
|
||||
|
||||
count_q = select(func.count(BotUser.id))
|
||||
if filters:
|
||||
count_q = count_q.where(and_(*filters))
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = select(BotUser).where(*filters).order_by(order).offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return JSONResponse({
|
||||
"total": total,
|
||||
"users": [
|
||||
{"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name,
|
||||
"last_name": u.last_name, "phone": u.phone, "email": u.email,
|
||||
"consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""}
|
||||
for u in users
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/bot/analytics")
|
||||
async def api_bot_analytics(db: AsyncSession = Depends(get_db),
|
||||
days: int = Query(30)):
|
||||
from app.models.models import BotConversation, BotUser
|
||||
from datetime import timedelta
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
total_consented = await db.execute(
|
||||
select(func.count(BotUser.id)).where(BotUser.consent_given == True))
|
||||
total_convs = await db.execute(
|
||||
select(func.count(BotConversation.id)).where(BotConversation.created_at >= since))
|
||||
open_convs = await db.execute(select(func.count(BotConversation.id)).where(
|
||||
BotConversation.status == "open", BotConversation.created_at >= since))
|
||||
closed_convs = await db.execute(select(func.count(BotConversation.id)).where(
|
||||
BotConversation.status == "closed", BotConversation.created_at >= since))
|
||||
|
||||
return JSONResponse({
|
||||
"total_users": total_consented.scalar(),
|
||||
"conversations_period": total_convs.scalar(),
|
||||
"open_conversations": open_convs.scalar(),
|
||||
"closed_conversations": closed_convs.scalar(),
|
||||
"period_days": days,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/bot/test-run")
|
||||
async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
body = await request.json()
|
||||
scenario = body.get("scenario", "new_dialog")
|
||||
messages = body.get("messages", [])
|
||||
|
||||
settings = SettingsCache.get_all()
|
||||
features = {k: v.enabled for k, v in SettingsCache.get_enabled_features().items()}
|
||||
categories = [{"id": c.id, "name": c.name} for c in SettingsCache.get_categories()]
|
||||
|
||||
test_api = MaxAPIClient("test_token", test_mode=True)
|
||||
conversation_log = []
|
||||
|
||||
try:
|
||||
from app.fsm import FSM, BotState
|
||||
from app.conversation import get_or_create_user, add_message
|
||||
await SettingsCache.refresh(db)
|
||||
|
||||
mock_user_id = body.get("mock_user_id", 999999999)
|
||||
user = await get_or_create_user(db, mock_user_id)
|
||||
if not user:
|
||||
user = await get_or_create_user(db, mock_user_id)
|
||||
FSM.set_state(mock_user_id, BotState.IDLE)
|
||||
|
||||
if scenario == "new_dialog":
|
||||
update = {
|
||||
"update_type": "bot_started",
|
||||
"user": {"user_id": mock_user_id},
|
||||
}
|
||||
await handle_update(None, update, db, test_api)
|
||||
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
|
||||
test_api.test_messages.clear()
|
||||
elif scenario == "out_of_hours":
|
||||
update = {
|
||||
"update_type": "bot_started",
|
||||
"user": {"user_id": mock_user_id},
|
||||
}
|
||||
await handle_update(None, update, db, test_api)
|
||||
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
|
||||
test_api.test_messages.clear()
|
||||
elif scenario == "returning":
|
||||
conv = await get_or_create_user(db, mock_user_id)
|
||||
user = await get_or_create_user(db, mock_user_id)
|
||||
from app.conversation import create_conversation
|
||||
from app.models.models import BotConversation
|
||||
existing = await db.execute(
|
||||
select(BotConversation).where(BotConversation.user_id == user.id).order_by(BotConversation.created_at.desc())
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
await create_conversation(db, user.id, "open", "Test inquiry")
|
||||
await db.commit()
|
||||
update = {
|
||||
"update_type": "bot_started",
|
||||
"user": {"user_id": mock_user_id},
|
||||
}
|
||||
await handle_update(None, update, db, test_api)
|
||||
conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages]
|
||||
test_api.test_messages.clear()
|
||||
|
||||
for msg_text in messages:
|
||||
update = {
|
||||
"update_type": "message_created",
|
||||
"user": {"user_id": mock_user_id},
|
||||
"message": {
|
||||
"body": {"text": msg_text},
|
||||
"attachments": [],
|
||||
},
|
||||
}
|
||||
await handle_update(None, update, db, test_api)
|
||||
conversation_log.append({"role": "user", "text": msg_text})
|
||||
for bot_msg in test_api.test_messages:
|
||||
conversation_log.append({"role": "bot", "text": bot_msg["text"]})
|
||||
test_api.test_messages.clear()
|
||||
|
||||
await db.rollback()
|
||||
FSM.clear_all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test-run error: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
return JSONResponse({
|
||||
"scenario": scenario,
|
||||
"conversation": conversation_log,
|
||||
"settings": settings,
|
||||
"enabled_features": features,
|
||||
"categories": categories,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/bot/broadcast/recipients")
|
||||
async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db),
|
||||
q: str = Query(None),
|
||||
page: int = Query(1), limit: int = Query(200)):
|
||||
from app.models.models import BotUser
|
||||
filters = [BotUser.consent_given == True]
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
filters.append(
|
||||
BotUser.first_name.ilike(like) |
|
||||
BotUser.last_name.ilike(like) |
|
||||
BotUser.phone.ilike(like) |
|
||||
BotUser.email.ilike(like)
|
||||
)
|
||||
count_q = select(func.count(BotUser.id)).where(and_(*filters))
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
query = select(BotUser).where(and_(*filters)).order_by(BotUser.first_name).offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
return JSONResponse({
|
||||
"total": total,
|
||||
"users": [
|
||||
{"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name,
|
||||
"last_name": u.last_name, "phone": u.phone, "email": u.email,
|
||||
"consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""}
|
||||
for u in users
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/bot/broadcast")
|
||||
async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotBroadcast, BotUser
|
||||
body = await request.json()
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
return JSONResponse({"error": "Text is required"}, status_code=400)
|
||||
|
||||
action = body.get("action", "send")
|
||||
user_ids = body.get("user_ids", None) # None or [] = all consenting users
|
||||
draft_id = body.get("draft_id")
|
||||
|
||||
# Build recipient list: only consenting users
|
||||
filters = [BotUser.consent_given == True]
|
||||
if user_ids is not None and len(user_ids) > 0:
|
||||
filters.append(BotUser.max_user_id.in_(user_ids))
|
||||
|
||||
result = await db.execute(select(BotUser.max_user_id, BotUser.first_name, BotUser.last_name).where(and_(*filters)))
|
||||
recipients = result.all()
|
||||
recipient_ids = [r[0] for r in recipients]
|
||||
recipient_names = [f"{r[1]} {r[2]}".strip() or f"id{r[0]}" for r in recipients]
|
||||
|
||||
if action == "preview":
|
||||
# Create or reuse draft
|
||||
if draft_id:
|
||||
result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id))
|
||||
broadcast = result.scalar_one_or_none()
|
||||
if not broadcast:
|
||||
return JSONResponse({"error": "Draft not found"}, status_code=404)
|
||||
broadcast.text = text
|
||||
broadcast.recipient_count = len(recipient_ids)
|
||||
broadcast.recipient_ids = recipient_ids
|
||||
else:
|
||||
broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids),
|
||||
recipient_ids=recipient_ids, status="draft")
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
return JSONResponse({
|
||||
"draft_id": broadcast.id,
|
||||
"text": text,
|
||||
"recipient_count": len(recipient_ids),
|
||||
"recipients": recipient_names,
|
||||
})
|
||||
|
||||
# action == "send"
|
||||
if draft_id:
|
||||
result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id))
|
||||
broadcast = result.scalar_one_or_none()
|
||||
if broadcast:
|
||||
broadcast.text = text
|
||||
broadcast.recipient_count = len(recipient_ids)
|
||||
broadcast.recipient_ids = recipient_ids
|
||||
broadcast.status = "sent"
|
||||
broadcast.sent_at = datetime.now()
|
||||
else:
|
||||
return JSONResponse({"error": "Draft not found"}, status_code=404)
|
||||
else:
|
||||
broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids),
|
||||
recipient_ids=recipient_ids, status="sent", sent_at=datetime.now())
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
|
||||
sent_count = 0
|
||||
if max_api:
|
||||
for uid in recipient_ids:
|
||||
try:
|
||||
await max_api.send_message(uid, text, format="markdown")
|
||||
sent_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "sent_to": sent_count, "total_recipients": len(recipient_ids)})
|
||||
|
||||
|
||||
@app.get("/api/bot/broadcast/history")
|
||||
async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db),
|
||||
page: int = Query(1), limit: int = Query(20)):
|
||||
from app.models.models import BotBroadcast
|
||||
count_q = select(func.count(BotBroadcast.id)).where(BotBroadcast.status == "sent")
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
query = select(BotBroadcast).where(BotBroadcast.status == "sent").order_by(
|
||||
BotBroadcast.sent_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(query)
|
||||
broadcasts = result.scalars().all()
|
||||
return JSONResponse({
|
||||
"total": total,
|
||||
"broadcasts": [
|
||||
{"id": b.id, "text": b.text[:200], "recipient_count": b.recipient_count,
|
||||
"sent_at": b.sent_at.isoformat() if b.sent_at else ""}
|
||||
for b in broadcasts
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# ===================== Risk Questions API =====================
|
||||
|
||||
@app.get("/api/bot/risk-questions")
|
||||
async def api_bot_risk_questions(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotRiskQuestion
|
||||
result = await db.execute(
|
||||
select(BotRiskQuestion).order_by(BotRiskQuestion.sort_order)
|
||||
)
|
||||
questions = result.scalars().all()
|
||||
return JSONResponse([
|
||||
{
|
||||
"id": q.id, "question_key": q.question_key,
|
||||
"question": q.question, "weight": q.weight,
|
||||
"sort_order": q.sort_order, "is_active": q.is_active,
|
||||
}
|
||||
for q in questions
|
||||
])
|
||||
|
||||
|
||||
@app.post("/api/bot/risk-questions/create")
|
||||
async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotRiskQuestion
|
||||
body = await request.json()
|
||||
q = BotRiskQuestion(
|
||||
question_key=body["question_key"],
|
||||
question=body["question"],
|
||||
weight=int(body.get("weight", 10)),
|
||||
sort_order=int(body.get("sort_order", 0)),
|
||||
is_active=body.get("is_active", True),
|
||||
)
|
||||
db.add(q)
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True, "id": q.id})
|
||||
|
||||
|
||||
@app.post("/api/bot/risk-questions/edit")
|
||||
async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotRiskQuestion
|
||||
body = await request.json()
|
||||
qid = body.get("id")
|
||||
if not qid:
|
||||
return JSONResponse({"ok": False, "error": "id required"}, status_code=400)
|
||||
q = await db.get(BotRiskQuestion, int(qid))
|
||||
if not q:
|
||||
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
|
||||
if "question_key" in body:
|
||||
q.question_key = body["question_key"]
|
||||
if "question" in body:
|
||||
q.question = body["question"]
|
||||
if "weight" in body:
|
||||
q.weight = int(body["weight"])
|
||||
if "sort_order" in body:
|
||||
q.sort_order = int(body["sort_order"])
|
||||
if "is_active" in body:
|
||||
q.is_active = body["is_active"]
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/risk-questions/toggle")
|
||||
async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotRiskQuestion
|
||||
body = await request.json()
|
||||
q = await db.get(BotRiskQuestion, int(body["id"]))
|
||||
if not q:
|
||||
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
|
||||
q.is_active = not q.is_active
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/risk-questions/delete")
|
||||
async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotRiskQuestion
|
||||
body = await request.json()
|
||||
q = await db.get(BotRiskQuestion, int(body["id"]))
|
||||
if not q:
|
||||
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
|
||||
await db.delete(q)
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ===================== Tickets API =====================
|
||||
|
||||
@app.get("/api/bot/tickets")
|
||||
async def api_bot_tickets(db: AsyncSession = Depends(get_db),
|
||||
status: str = Query(None),
|
||||
priority: str = Query(None)):
|
||||
from app.models.models import BotTicket, BotUser
|
||||
query = select(BotTicket, BotUser.first_name, BotUser.last_name).join(
|
||||
BotUser, BotTicket.user_id == BotUser.id, isouter=True
|
||||
)
|
||||
if status:
|
||||
query = query.where(BotTicket.status == status)
|
||||
if priority:
|
||||
query = query.where(BotTicket.priority == priority)
|
||||
query = query.order_by(BotTicket.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
return JSONResponse([
|
||||
{
|
||||
"id": t.id, "ticket_number": t.ticket_number,
|
||||
"title": t.title, "description": t.description[:200],
|
||||
"priority": t.priority, "status": t.status,
|
||||
"user_name": f"{fn or ''} {ln or ''}".strip() if fn else None,
|
||||
"object_name": None,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else "",
|
||||
}
|
||||
for t, fn, ln in rows
|
||||
])
|
||||
|
||||
|
||||
@app.get("/api/bot/tickets/{ticket_id}")
|
||||
async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotTicket, BotUser, BotTicketMessage
|
||||
t = await db.get(BotTicket, ticket_id)
|
||||
if not t:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
user = await db.get(BotUser, t.user_id) if t.user_id else None
|
||||
result = await db.execute(
|
||||
select(BotTicketMessage).where(BotTicketMessage.ticket_id == ticket_id)
|
||||
.order_by(BotTicketMessage.created_at)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
return JSONResponse({
|
||||
"id": t.id, "ticket_number": t.ticket_number,
|
||||
"title": t.title, "description": t.description,
|
||||
"priority": t.priority, "status": t.status,
|
||||
"user_name": f"{user.first_name} {user.last_name}".strip() if user else None,
|
||||
"object_name": None,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else "",
|
||||
"messages": [
|
||||
{"id": m.id, "direction": m.direction, "text": m.text,
|
||||
"sender": m.sender,
|
||||
"timestamp": m.created_at.isoformat() if m.created_at else ""}
|
||||
for m in messages
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/bot/tickets/create")
|
||||
async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus
|
||||
from sqlalchemy import func
|
||||
body = await request.json()
|
||||
count = (await db.execute(select(func.count(BotTicket.id)))).scalar() or 0
|
||||
ticket_num = f"T-{count + 1:04d}"
|
||||
t = BotTicket(
|
||||
ticket_number=ticket_num,
|
||||
title=body.get("title", ""),
|
||||
description=body.get("description", ""),
|
||||
priority=body.get("priority", "normal"),
|
||||
status="open",
|
||||
created_by=body.get("created_by", "portal"),
|
||||
)
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
db.add(BotTicketMessage(ticket_id=t.id, direction="in", text=body.get("description", ""), sender="client"))
|
||||
db.add(BotTicketStatus(ticket_id=t.id, old_status="", new_status="open", changed_by="portal"))
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True, "id": t.id, "ticket_number": ticket_num})
|
||||
|
||||
|
||||
@app.post("/api/bot/tickets/{ticket_id}/message")
|
||||
async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotTicketMessage
|
||||
body = await request.json()
|
||||
m = BotTicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
direction=body.get("direction", "in"),
|
||||
text=body.get("text", ""),
|
||||
sender=body.get("sender", ""),
|
||||
)
|
||||
db.add(m)
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/tickets/{ticket_id}/status")
|
||||
async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotTicket, BotTicketStatus
|
||||
body = await request.json()
|
||||
t = await db.get(BotTicket, ticket_id)
|
||||
if not t:
|
||||
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
|
||||
old = t.status
|
||||
new_status = body.get("status", old)
|
||||
db.add(BotTicketStatus(ticket_id=t.id, old_status=old, new_status=new_status, changed_by="portal"))
|
||||
t.status = new_status
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/engineers")
|
||||
async def api_bot_engineers(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import User
|
||||
result = await db.execute(
|
||||
select(User.id, User.full_name).where(
|
||||
User.is_active == True,
|
||||
User.role.in_(["engineer", "owner"])
|
||||
).order_by(User.full_name)
|
||||
)
|
||||
return JSONResponse([{"id": row[0], "full_name": row[1]} for row in result.fetchall()])
|
||||
|
||||
|
||||
@app.get("/api/bot/handoffs")
|
||||
async def api_bot_handoffs(db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotConversation, BotUser
|
||||
query = (
|
||||
select(BotConversation, BotUser.first_name, BotUser.max_user_id)
|
||||
.join(BotUser)
|
||||
.where(BotConversation.status == "handoff")
|
||||
.order_by(BotConversation.created_at.desc())
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
return JSONResponse([
|
||||
{
|
||||
"id": c.id, "user_name": fn, "max_user_id": muid,
|
||||
"text": c.inquiry_text[:200] if c.inquiry_text else "",
|
||||
"status": c.status, "priority": c.priority,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else "",
|
||||
}
|
||||
for c, fn, muid in rows
|
||||
])
|
||||
|
||||
|
||||
@app.post("/api/bot/handoffs/{handoff_id}/take")
|
||||
async def api_bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotConversation
|
||||
conv = await db.get(BotConversation, handoff_id)
|
||||
if not conv or conv.status != "handoff":
|
||||
return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404)
|
||||
conv.status = "open"
|
||||
conv.priority = "normal"
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/bot/handoffs/{handoff_id}/close")
|
||||
async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotConversation
|
||||
from datetime import datetime
|
||||
conv = await db.get(BotConversation, handoff_id)
|
||||
if not conv or conv.status != "handoff":
|
||||
return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404)
|
||||
conv.status = "closed"
|
||||
conv.closed_at = datetime.now()
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/bot/notifications")
|
||||
async def api_bot_notifications(db: AsyncSession = Depends(get_db),
|
||||
page: int = Query(1), limit: int = Query(50)):
|
||||
from app.models.models import BotNotification
|
||||
count_q = select(func.count(BotNotification.id))
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
query = (
|
||||
select(BotNotification)
|
||||
.order_by(BotNotification.created_at.desc())
|
||||
.offset((page - 1) * limit).limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
notifications = result.scalars().all()
|
||||
return JSONResponse({
|
||||
"total": total,
|
||||
"notifications": [
|
||||
{
|
||||
"id": n.id, "type": n.type, "recipient_type": n.recipient_type,
|
||||
"title": n.title, "body": n.body[:200],
|
||||
"is_read": n.is_read,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else "",
|
||||
}
|
||||
for n in notifications
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/bot/notifications/{notif_id}/read")
|
||||
async def api_bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.models.models import BotNotification
|
||||
n = await db.get(BotNotification, notif_id)
|
||||
if not n:
|
||||
return JSONResponse({"ok": False, "error": "Not found"}, status_code=404)
|
||||
n.is_read = True
|
||||
await db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import re
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_API_URL = "https://platform-api.max.ru"
|
||||
|
||||
|
||||
class MaxAPIClient:
|
||||
def __init__(self, token: str, test_mode: bool = False):
|
||||
self.token = token
|
||||
self.test_mode = test_mode
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self.test_messages: list = []
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": self.token, "Content-Type": "application/json"}
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
if self.test_mode:
|
||||
logger.info(f"[TEST_MODE] {method} {path} kwargs={kwargs}")
|
||||
return {"ok": True, "test_mode": True}
|
||||
session = await self._get_session()
|
||||
url = f"{MAX_API_URL}{path}"
|
||||
async with session.request(method, url, headers=self._headers(), **kwargs) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
logger.error(f"Max API error {resp.status}: {body}")
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def get_me(self) -> dict:
|
||||
return await self._request("GET", "/me")
|
||||
|
||||
async def send_message(self, user_id: int = None, chat_id: int = None, text: str = "",
|
||||
attachments: list = None, format: str = None,
|
||||
disable_link_preview: bool = False, notify: bool = True) -> dict:
|
||||
body = {"text": text}
|
||||
if attachments:
|
||||
body["attachments"] = attachments
|
||||
if format:
|
||||
body["format"] = format
|
||||
if not disable_link_preview:
|
||||
body["disable_link_preview"] = False
|
||||
if not notify:
|
||||
body["notify"] = False
|
||||
|
||||
params = {}
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if chat_id:
|
||||
params["chat_id"] = chat_id
|
||||
|
||||
if self.test_mode:
|
||||
msg = {"role": "bot", "text": text, "format": format}
|
||||
if attachments:
|
||||
msg["attachments"] = attachments
|
||||
self.test_messages.append(msg)
|
||||
return await self._request("POST", "/messages", json=body, params=params)
|
||||
|
||||
async def send_message_with_keyboard(self, user_id: int, text: str, buttons: list,
|
||||
format: str = None) -> dict:
|
||||
attachments = [{
|
||||
"type": "inline_keyboard",
|
||||
"payload": {"buttons": buttons}
|
||||
}]
|
||||
return await self.send_message(user_id=user_id, text=text, attachments=attachments, format=format)
|
||||
|
||||
async def edit_message(self, message_id: int, text: str, attachments: list = None) -> dict:
|
||||
body = {"text": text}
|
||||
if attachments:
|
||||
body["attachments"] = attachments
|
||||
return await self._request("PUT", f"/messages/{message_id}", json=body)
|
||||
|
||||
async def get_message(self, message_id: int) -> dict:
|
||||
return await self._request("GET", f"/messages/{message_id}")
|
||||
|
||||
async def get_messages(self, user_id: int = None, chat_id: int = None, limit: int = 10) -> dict:
|
||||
params = {"limit": limit}
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if chat_id:
|
||||
params["chat_id"] = chat_id
|
||||
return await self._request("GET", "/messages", params=params)
|
||||
|
||||
async def answer_callback(self, callback_id: str, text: str = "", show_alert: bool = False) -> dict:
|
||||
body = {"callback_id": callback_id}
|
||||
if text:
|
||||
body["text"] = text
|
||||
if show_alert:
|
||||
body["show_alert"] = True
|
||||
return await self._request("POST", "/answers", json=body)
|
||||
|
||||
async def subscribe_webhook(self, url: str, update_types: list = None, secret: str = None) -> dict:
|
||||
body = {"url": url}
|
||||
if update_types:
|
||||
body["update_types"] = update_types
|
||||
if secret:
|
||||
body["secret"] = secret
|
||||
return await self._request("POST", "/subscriptions", json=body)
|
||||
|
||||
async def get_subscriptions(self) -> dict:
|
||||
return await self._request("GET", "/subscriptions")
|
||||
|
||||
async def unsubscribe_webhook(self) -> dict:
|
||||
return await self._request("DELETE", "/subscriptions")
|
||||
|
||||
async def upload_file(self, file_path: str) -> dict:
|
||||
session = await self._get_session()
|
||||
url = f"{MAX_API_URL}/uploads"
|
||||
with open(file_path, "rb") as f:
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("file", f, filename=os.path.basename(file_path))
|
||||
async with session.post(url, headers={"Authorization": self.token}, data=data) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
def verify_webhook_secret(self, header_value: str, expected_secret: str) -> bool:
|
||||
if not expected_secret:
|
||||
return True
|
||||
return hmac.compare_digest(header_value, expected_secret)
|
||||
|
||||
async def send_file(self, user_id: int, file_path: str, caption: str = "") -> dict:
|
||||
upload = await self.upload_file(file_path)
|
||||
file_id = upload.get("file_id", "")
|
||||
body = {"text": caption, "attachments": [{"type": "file", "payload": {"file_id": file_id}}]}
|
||||
return await self._request("POST", "/messages", json=body, params={"user_id": user_id})
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
resp = await self._request("GET", f"/uploads/{file_id}")
|
||||
return resp.get("url", "")
|
||||
|
||||
async def send_contact_request(self, user_id: int, text: str = "Пожалуйста, отправьте ваш контакт") -> dict:
|
||||
attachments = [{
|
||||
"type": "contact_request",
|
||||
"payload": {"text": text}
|
||||
}]
|
||||
return await self.send_message(user_id=user_id, text=text, attachments=attachments)
|
||||
|
||||
def verify_contact_hash(self, vcf_info: str, access_token: str, expected_hash: str) -> bool:
|
||||
vcf_normalized = vcf_info.replace("\\r\\n", "\r\n")
|
||||
computed = hmac.new(access_token.encode(), vcf_normalized.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(computed, expected_hash)
|
||||
@@ -0,0 +1,148 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import (
|
||||
BotNotification, BotNotificationSubscription, BotUser,
|
||||
)
|
||||
from app.settings_cache import SettingsCache
|
||||
|
||||
try:
|
||||
from app.models.models import User
|
||||
except ImportError:
|
||||
User = None
|
||||
|
||||
|
||||
async def _get_max_user_id_by_role(db: AsyncSession, role: str) -> int | None:
|
||||
if User is None:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(User.max_user_id).where(
|
||||
User.role == role, User.is_active == True,
|
||||
User.max_user_id.isnot(None)
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_user_max_ids_by_role(db: AsyncSession, role: str) -> list[int]:
|
||||
if User is None:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(User.max_user_id).where(
|
||||
User.role == role, User.is_active == True,
|
||||
User.max_user_id.isnot(None)
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.fetchall() if row[0]]
|
||||
|
||||
|
||||
async def _log_notification(db: AsyncSession, ntype: str, recipient_type: str,
|
||||
recipient_max_user_id: int | None, title: str, body: str,
|
||||
related_type: str = "", related_id: int | None = None):
|
||||
n = BotNotification(
|
||||
type=ntype, recipient_type=recipient_type,
|
||||
recipient_max_user_id=recipient_max_user_id,
|
||||
title=title, body=body,
|
||||
related_type=related_type, related_id=related_id
|
||||
)
|
||||
db.add(n)
|
||||
await db.flush()
|
||||
return n
|
||||
|
||||
|
||||
async def notify_operator(db: AsyncSession, max_api, text: str, title: str = "",
|
||||
related_type: str = "", related_id: int | None = None):
|
||||
max_ids = await _get_user_max_ids_by_role(db, "owner")
|
||||
sent_count = 0
|
||||
for mid in max_ids:
|
||||
try:
|
||||
await max_api.send_message(user_id=mid, text=text, format="markdown")
|
||||
sent_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
await _log_notification(
|
||||
db, "operator_alert", "operator", max_ids[0] if max_ids else None,
|
||||
title or "Уведомление оператору", text,
|
||||
related_type, related_id
|
||||
)
|
||||
return sent_count
|
||||
|
||||
|
||||
async def notify_level2(db: AsyncSession, max_api, text: str, title: str = "",
|
||||
related_type: str = "", related_id: int | None = None):
|
||||
max_ids = await _get_user_max_ids_by_role(db, "engineer")
|
||||
sent_count = 0
|
||||
for mid in max_ids:
|
||||
try:
|
||||
await max_api.send_message(user_id=mid, text=text, format="markdown")
|
||||
sent_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
await _log_notification(
|
||||
db, "level2_alert", "level2", max_ids[0] if max_ids else None,
|
||||
title or "Уведомление инженеру", text,
|
||||
related_type, related_id
|
||||
)
|
||||
return sent_count
|
||||
|
||||
|
||||
async def notify_client_status_change(db: AsyncSession, max_api,
|
||||
bot_user: BotUser, conv_id: int,
|
||||
new_status: str):
|
||||
text = f"Статус вашего обращения №{conv_id} изменён на «{new_status}»."
|
||||
try:
|
||||
await max_api.send_message(user_id=bot_user.max_user_id, text=text, format="markdown")
|
||||
except Exception:
|
||||
pass
|
||||
await _log_notification(
|
||||
db, "status_change", "client", bot_user.max_user_id,
|
||||
"Статус обращения", text, "conversation", conv_id
|
||||
)
|
||||
|
||||
|
||||
async def notify_ticket_created(db: AsyncSession, ticket, max_api):
|
||||
ticket_text = (
|
||||
f"🆕 **Новый тикет {ticket.ticket_number}**\n"
|
||||
f"Приоритет: {ticket.priority}\n"
|
||||
f"Описание: {ticket.description[:200]}\n"
|
||||
f"Обращение №{ticket.id}"
|
||||
)
|
||||
await notify_level2(
|
||||
db, max_api, ticket_text,
|
||||
title=f"Тикет {ticket.ticket_number}",
|
||||
related_type="ticket", related_id=ticket.id
|
||||
)
|
||||
|
||||
|
||||
async def notify_handoff_escalation(db: AsyncSession, max_api,
|
||||
bot_user: BotUser, conv_id: int,
|
||||
text: str = ""):
|
||||
msg = (
|
||||
f"🔴 **Эскалация оператору**\n"
|
||||
f"Пользователь: {bot_user.first_name or '—'} (ID {bot_user.max_user_id})\n"
|
||||
f"Обращение №{conv_id}\n"
|
||||
f"Текст: {text[:200] if text else '—'}"
|
||||
)
|
||||
await notify_operator(
|
||||
db, max_api, msg,
|
||||
title="Эскалация",
|
||||
related_type="conversation", related_id=conv_id
|
||||
)
|
||||
|
||||
|
||||
async def get_subscribed_users(db: AsyncSession, notify_type: str = "broadcast") -> list[BotUser]:
|
||||
col_map = {
|
||||
"broadcast": BotNotificationSubscription.notify_on_broadcast,
|
||||
"status_change": BotNotificationSubscription.notify_on_status_change,
|
||||
"reply": BotNotificationSubscription.notify_on_reply,
|
||||
}
|
||||
col = col_map.get(notify_type)
|
||||
if col is None:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(BotUser).join(
|
||||
BotNotificationSubscription,
|
||||
BotNotificationSubscription.user_id == BotUser.id
|
||||
).where(col == True)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,350 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, DateTime, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, text as sa_text
|
||||
)
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class BotUser(Base):
|
||||
__tablename__ = "bot_users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
max_user_id = Column(BigInteger, unique=True, nullable=False)
|
||||
first_name = Column(String(255), default="")
|
||||
last_name = Column(String(255), default="")
|
||||
username = Column(String(255), default="")
|
||||
phone = Column(String(50), default="")
|
||||
email = Column(String(255), default="")
|
||||
consent_given = Column(Boolean, nullable=False, default=False)
|
||||
consent_timestamp = Column(DateTime, nullable=True)
|
||||
consent_method = Column(String(50), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
last_active_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
conversations = relationship("BotConversation", back_populates="user")
|
||||
consent_logs = relationship("BotConsentLog", back_populates="user")
|
||||
notification_subscriptions = relationship("BotNotificationSubscription", back_populates="user", uselist=False)
|
||||
|
||||
|
||||
class BotConversation(Base):
|
||||
__tablename__ = "bot_conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="open")
|
||||
inquiry_type = Column(Integer, ForeignKey("bot_categories.id", ondelete="SET NULL"), nullable=True)
|
||||
inquiry_text = Column(Text, default="")
|
||||
priority = Column(String(20), default="normal")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
assigned_to = Column(String(255), default="")
|
||||
has_attachment = Column(Boolean, default=False)
|
||||
attachment_type = Column(String(50), default="")
|
||||
attachment_path = Column(String(500), default="")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('open','closed','handoff','1c_sent','awaiting_rating')", name="ck_bot_conv_status"),
|
||||
CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_conv_priority"),
|
||||
)
|
||||
|
||||
user = relationship("BotUser", back_populates="conversations")
|
||||
category = relationship("BotCategory", back_populates="conversations")
|
||||
messages = relationship("BotMessage", back_populates="conversation", order_by="BotMessage.timestamp")
|
||||
sync_log = relationship("Bot1CSyncLog", back_populates="conversation")
|
||||
|
||||
|
||||
class BotMessage(Base):
|
||||
__tablename__ = "bot_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False)
|
||||
direction = Column(String(3), nullable=False)
|
||||
text = Column(Text, default="")
|
||||
has_attachment = Column(Boolean, default=False)
|
||||
attachment_type = Column(String(50), default="")
|
||||
attachment_path = Column(String(500), default="")
|
||||
max_message_id = Column(BigInteger, nullable=True)
|
||||
timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('in','out')", name="ck_bot_msg_direction"),
|
||||
)
|
||||
|
||||
conversation = relationship("BotConversation", back_populates="messages")
|
||||
|
||||
|
||||
class BotConsentLog(Base):
|
||||
__tablename__ = "bot_consent_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False)
|
||||
action = Column(String(20), nullable=False)
|
||||
timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
method = Column(String(50), default="")
|
||||
ip_address = Column(String(45), default="")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("action IN ('given','revoked')", name="ck_bot_consent_action"),
|
||||
)
|
||||
|
||||
user = relationship("BotUser", back_populates="consent_logs")
|
||||
|
||||
|
||||
class Bot1CSyncLog(Base):
|
||||
__tablename__ = "bot_1c_sync_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
request_payload = Column(Text, default="")
|
||||
response_payload = Column(Text, default="")
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
confirmed_at = Column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending','sent','confirmed','error')", name="ck_bot_1c_status"),
|
||||
)
|
||||
|
||||
conversation = relationship("BotConversation", back_populates="sync_log")
|
||||
|
||||
|
||||
class BotKnowledgeBase(Base):
|
||||
__tablename__ = "bot_knowledge_base"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
question = Column(String(500), nullable=False)
|
||||
answer = Column(Text, nullable=False)
|
||||
category = Column(String(100), default="")
|
||||
keywords = Column(JSON, default=list)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
|
||||
class BotCategory(Base):
|
||||
__tablename__ = "bot_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(String(500), default="")
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
icon_emoji = Column(String(10), default="📋")
|
||||
keywords = Column(JSON, default=list)
|
||||
|
||||
conversations = relationship("BotConversation", back_populates="category")
|
||||
|
||||
|
||||
class BotSetting(Base):
|
||||
__tablename__ = "bot_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(100), unique=True, nullable=False)
|
||||
value = Column(Text, default="")
|
||||
description = Column(String(500), default="")
|
||||
|
||||
|
||||
class BotFeature(Base):
|
||||
__tablename__ = "bot_features"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
feature_key = Column(String(100), unique=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(String(500), default="")
|
||||
enabled = Column(Boolean, nullable=False, default=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
|
||||
class BotResponseTemplate(Base):
|
||||
__tablename__ = "bot_response_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_key = Column(String(100), unique=True, nullable=False)
|
||||
template_text = Column(Text, nullable=False)
|
||||
description = Column(String(500), default="")
|
||||
variables = Column(JSON, default=list)
|
||||
|
||||
|
||||
class FileSyncQueue(Base):
|
||||
__tablename__ = "file_sync_queue"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
local_path = Column(String(500), nullable=False)
|
||||
remote_path = Column(String(500), default="")
|
||||
entity_type = Column(String(50), nullable=False)
|
||||
entity_id = Column(Integer, nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
error = Column(Text, default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending','uploading','synced','error')", name="ck_file_sync_status"),
|
||||
)
|
||||
|
||||
|
||||
class BotBroadcast(Base):
|
||||
__tablename__ = "bot_broadcasts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
text = Column(Text, nullable=False)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
sent_by = Column(Integer, nullable=True)
|
||||
recipient_count = Column(Integer, default=0)
|
||||
recipient_ids = Column(JSON, default=list)
|
||||
status = Column(String(20), default="draft")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('draft','sent','failed')", name="ck_bot_broadcast_status"),
|
||||
)
|
||||
|
||||
|
||||
class BotNotification(Base):
|
||||
__tablename__ = "bot_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
recipient_type = Column(String(20), nullable=False)
|
||||
recipient_max_user_id = Column(BigInteger, nullable=True)
|
||||
title = Column(String(255), default="")
|
||||
body = Column(Text, default="")
|
||||
related_type = Column(String(50), default="")
|
||||
related_id = Column(Integer, nullable=True)
|
||||
is_read = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("recipient_type IN ('operator','level2','client')", name="ck_bot_notif_recipient"),
|
||||
)
|
||||
|
||||
|
||||
class BotNotificationSubscription(Base):
|
||||
__tablename__ = "bot_notification_subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False)
|
||||
notify_on_status_change = Column(Boolean, nullable=False, default=False)
|
||||
notify_on_reply = Column(Boolean, nullable=False, default=False)
|
||||
notify_on_broadcast = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
user = relationship("BotUser", back_populates="notification_subscriptions")
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
contact_phone = Column(String(50), default="")
|
||||
|
||||
|
||||
class Object(Base):
|
||||
__tablename__ = "objects"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"), nullable=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="active")
|
||||
risk_score = Column(Integer, default=0)
|
||||
|
||||
|
||||
class SLAContract(Base):
|
||||
__tablename__ = "sla_contracts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="negotiation")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
service_level = Column(String(20), default="start")
|
||||
sla_price_monthly = Column(Integer, default=0)
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="open")
|
||||
|
||||
|
||||
class Incident(Base):
|
||||
__tablename__ = "incidents"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="open")
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
title = Column(String(500), default="")
|
||||
description = Column(Text, default="")
|
||||
severity = Column(String(4), nullable=False, default="P3")
|
||||
reported_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
login = Column(String(50), default="")
|
||||
full_name = Column(String(255), default="")
|
||||
role = Column(String(20), nullable=False, default="technician")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
max_user_id = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class BotRiskQuestion(Base):
|
||||
__tablename__ = "bot_risk_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
question_key = Column(String(100), unique=True, nullable=False)
|
||||
question = Column(String(500), nullable=False)
|
||||
weight = Column(Integer, nullable=False, default=10)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class BotTicket(Base):
|
||||
__tablename__ = "bot_tickets"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_number = Column(String(20), unique=True, nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="SET NULL"), nullable=True)
|
||||
object_id = Column(Integer, nullable=True)
|
||||
customer_id = Column(Integer, nullable=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, default="")
|
||||
priority = Column(String(20), default="normal")
|
||||
status = Column(String(20), nullable=False, default="open")
|
||||
created_by = Column(String(20), default="bot")
|
||||
sla_contract_id = Column(Integer, nullable=True)
|
||||
has_attachment = Column(Boolean, default=False)
|
||||
attachment_type = Column(String(50), default="")
|
||||
attachment_path = Column(String(500), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
updated_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_ticket_priority"),
|
||||
CheckConstraint("status IN ('open','in_progress','resolved','closed')", name="ck_bot_ticket_status"),
|
||||
)
|
||||
|
||||
|
||||
class BotTicketMessage(Base):
|
||||
__tablename__ = "bot_ticket_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
direction = Column(String(10), nullable=False)
|
||||
text = Column(Text, default="")
|
||||
sender = Column(String(50), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
|
||||
|
||||
class BotTicketStatus(Base):
|
||||
__tablename__ = "bot_ticket_statuses"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
old_status = Column(String(20), default="")
|
||||
new_status = Column(String(20), nullable=False)
|
||||
changed_by = Column(String(50), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP"))
|
||||
@@ -0,0 +1,129 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import BotSetting, BotFeature, BotResponseTemplate, BotCategory
|
||||
|
||||
|
||||
class SettingsCache:
|
||||
_cache: dict = {}
|
||||
_features: dict = {}
|
||||
_templates: dict = {}
|
||||
_categories: list = []
|
||||
_last_update: float = 0
|
||||
TTL = 300 # 5 minutes
|
||||
|
||||
@classmethod
|
||||
async def refresh(cls, db: AsyncSession):
|
||||
now = time.time()
|
||||
if now - cls._last_update < cls.TTL and cls._cache:
|
||||
return
|
||||
|
||||
result = await db.execute(select(BotSetting))
|
||||
cls._cache = {row.key: row.value for row in result.scalars().all()}
|
||||
|
||||
result = await db.execute(select(BotFeature).where(BotFeature.enabled == True))
|
||||
cls._features = {row.feature_key: row for row in result.scalars().all()}
|
||||
|
||||
result = await db.execute(select(BotFeature))
|
||||
all_features = {row.feature_key: row for row in result.scalars().all()}
|
||||
|
||||
result = await db.execute(select(BotResponseTemplate))
|
||||
cls._templates = {row.template_key: row.template_text for row in result.scalars().all()}
|
||||
|
||||
result = await db.execute(
|
||||
select(BotCategory).where(BotCategory.active == True).order_by(BotCategory.sort_order)
|
||||
)
|
||||
cls._categories = result.scalars().all()
|
||||
|
||||
cls._last_update = now
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: str, default: str = "") -> str:
|
||||
return cls._cache.get(key, default)
|
||||
|
||||
@classmethod
|
||||
def get_int(cls, key: str, default: int = 0) -> int:
|
||||
val = cls._cache.get(key, str(default))
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_list(cls, key: str, default: list = None) -> list:
|
||||
val = cls._cache.get(key, "")
|
||||
if not val:
|
||||
return default or []
|
||||
return [x.strip() for x in val.split(",")]
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> dict:
|
||||
return dict(cls._cache)
|
||||
|
||||
@classmethod
|
||||
def is_feature_enabled(cls, feature_key: str) -> bool:
|
||||
return feature_key in cls._features
|
||||
|
||||
@classmethod
|
||||
def get_enabled_features(cls) -> dict:
|
||||
return dict(cls._features)
|
||||
|
||||
@classmethod
|
||||
def get_template(cls, key: str, default: str = "") -> str:
|
||||
return cls._templates.get(key, default)
|
||||
|
||||
@classmethod
|
||||
def get_all_templates(cls) -> dict:
|
||||
return dict(cls._templates)
|
||||
|
||||
@classmethod
|
||||
def get_categories(cls) -> list:
|
||||
return list(cls._categories)
|
||||
|
||||
@classmethod
|
||||
def get_bot_token(cls) -> str:
|
||||
return cls._cache.get("bot_token", "")
|
||||
|
||||
@classmethod
|
||||
def get_assistant_name(cls) -> str:
|
||||
return cls._cache.get("assistant_name", "София")
|
||||
|
||||
@classmethod
|
||||
def is_work_hours(cls) -> bool:
|
||||
import datetime
|
||||
import pytz
|
||||
tz_name = cls._cache.get("timezone", "Europe/Moscow")
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except Exception:
|
||||
tz = pytz.timezone("Europe/Moscow")
|
||||
now = datetime.datetime.now(tz)
|
||||
work_days = cls.get_list("work_days", ["1", "2", "3", "4", "5"])
|
||||
weekday = str(now.isoweekday())
|
||||
if weekday not in work_days:
|
||||
return False
|
||||
start = cls.get_int("work_hours_start", 9)
|
||||
end = cls.get_int("work_hours_end", 18)
|
||||
return start <= now.hour < end
|
||||
|
||||
@classmethod
|
||||
def get_work_hours_str(cls) -> str:
|
||||
start = cls.get_int("work_hours_start", 9)
|
||||
end = cls.get_int("work_hours_end", 18)
|
||||
return f"{start}:00-{end}:00"
|
||||
|
||||
@classmethod
|
||||
def get_phones(cls) -> list:
|
||||
phones = []
|
||||
p1 = cls._cache.get("phone_1", "")
|
||||
p2 = cls._cache.get("phone_2", "")
|
||||
if p1:
|
||||
phones.append(p1)
|
||||
if p2:
|
||||
phones.append(p2)
|
||||
return phones
|
||||
|
||||
@classmethod
|
||||
def get_email(cls) -> str:
|
||||
return cls._cache.get("support_email", "")
|
||||
@@ -0,0 +1,34 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def render_template(template: str, **kwargs) -> str:
|
||||
result = template
|
||||
for key, value in kwargs.items():
|
||||
result = result.replace("{" + key + "}", str(value))
|
||||
remaining = re.findall(r'\{(\w+)\}', result)
|
||||
for var in remaining:
|
||||
result = result.replace("{" + var + "}", "")
|
||||
return result
|
||||
|
||||
|
||||
def parse_link_text(text: str) -> str:
|
||||
text = re.sub(r'\[([^\]]+)\]\(tel:[^\)]+\)', r'\1', text)
|
||||
text = re.sub(r'\[([^\]]+)\]\(mailto:[^\)]+\)', r'\1', text)
|
||||
text = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', text)
|
||||
return text
|
||||
|
||||
|
||||
def make_clickable_links(text: str, phone_1: str = "", phone_2: str = "",
|
||||
email: str = "", email_subject: str = "") -> str:
|
||||
phone_1_raw = re.sub(r'[^\d+]', '', phone_1)
|
||||
phone_2_raw = re.sub(r'[^\d+]', '', phone_2)
|
||||
email_encoded = email.replace(' ', '%20')
|
||||
subject_encoded = email_subject.replace(' ', '%20')
|
||||
|
||||
text = text.replace("{phone_1_raw}", phone_1_raw)
|
||||
text = text.replace("{phone_2_raw}", phone_2_raw)
|
||||
text = text.replace("{email_encoded}", email_encoded)
|
||||
text = text.replace("{email_subject_encoded}", subject_encoded)
|
||||
|
||||
return text
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.integrations.yandex_gpt import analyze_emotion_with_gpt
|
||||
|
||||
_NEGATIVE_WORDS = [
|
||||
"ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо",
|
||||
"бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь",
|
||||
"раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд",
|
||||
"верните деньги", "мошенники", "обман",
|
||||
]
|
||||
|
||||
|
||||
async def detect_emotion(text: str) -> str:
|
||||
gpt_key = SettingsCache.get("yandex_gpt_key")
|
||||
if gpt_key:
|
||||
try:
|
||||
result = await analyze_emotion_with_gpt(text)
|
||||
if result in ("positive", "neutral", "negative"):
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return _keyword_fallback(text)
|
||||
|
||||
|
||||
def _keyword_fallback(text: str) -> str:
|
||||
text_lower = text.lower()
|
||||
if any(word in text_lower for word in _NEGATIVE_WORDS):
|
||||
return "negative"
|
||||
return "neutral"
|
||||
Reference in New Issue
Block a user