#!/usr/bin/env python3
"""
Legal Advisor Router (Юридический Помощник Домового 2.0).
Обрабатывает текстовые и голосовые юридические вопросы жильцов и совета дома.
Поддерживает ролевую модель:
- Жилец: простой бытовой язык + бланки заявлений.
- Юрист (has_ai_access=1): статьи ЖК РФ, судебная практика, досудебные претензии.
- Админ (Александр Тырин): стратегический анализ, сопоставление ответов чиновников.
Строгий шлюз Zero-Spam: ответы выдаются в личку либо направляются на одобрение админу.
"""
import os
import re
import html
import logging
from pathlib import Path
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select
from config import ADMIN_USER_ID
from database.db import AsyncSessionLocal
from database.models import User
from services.ai_legal_engine import legal_engine
log = logging.getLogger("LegalAdvisorHandler")
router = Router()
TEMP_VOICE_DIR = Path(__file__).resolve().parent.parent / "temp_voice"
TEMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
class LegalQuestionState(StatesGroup):
waiting_for_question = State()
def format_telegram_html(text: str) -> str:
"""Безопасное форматирование текста Markdown -> HTML для Telegram."""
clean = text.replace("
", "\n").replace("
", "\n")
safe = html.escape(clean)
# **жирный** -> жирный
safe = re.sub(r'\*\*(.*?)\*\*', r'\1', safe, flags=re.DOTALL)
# *курсив* -> курсив
safe = re.sub(r'\*(.*?)\*', r'\1', safe, flags=re.DOTALL)
# [ссылка](url) -> ссылка
safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', r'\1', safe, flags=re.DOTALL)
return safe
async def send_split_message(message: Message, header: str, body: str):
"""Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
# Разбиваем исходный текст по параграфам
paragraphs = body.split("\n\n")
chunks = []
current_chunk = []
current_len = 0
for p in paragraphs:
p_len = len(p) + 2
if current_len + p_len > 3200 and current_chunk:
chunks.append("\n\n".join(current_chunk))
current_chunk = [p]
current_len = p_len
else:
current_chunk.append(p)
current_len += p_len
if current_chunk:
chunks.append("\n\n".join(current_chunk))
if not chunks:
chunks = [body]
for idx, chunk in enumerate(chunks, 1):
if idx == 1:
if header:
part_header = f"{header}\n\n" if len(chunks) == 1 else f"{header} (Часть 1/{len(chunks)}):\n\n"
else:
part_header = "" if len(chunks) == 1 else f"(Часть 1/{len(chunks)}):\n\n"
else:
part_header = f"(Продолжение {idx}/{len(chunks)}):\n\n"
part_text = f"{part_header}{format_telegram_html(chunk)}"
# Прикрепляем инлайн-кнопки только к последней части ответа
reply_markup = get_legal_nav_keyboard() if idx == len(chunks) else None
try:
await message.answer(part_text, parse_mode='HTML', reply_markup=reply_markup)
except Exception as e:
log.warning(f"HTML send failed ({e}), falling back to plain text")
if header:
plain_header = header if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):"
await message.answer(f"{plain_header}\n\n{chunk}", reply_markup=reply_markup)
else:
plain_header = "" if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):\n\n"
await message.answer(f"{plain_header}{chunk}", reply_markup=reply_markup)
def get_legal_nav_keyboard() -> InlineKeyboardMarkup:
"""Инлайн-кнопки навигации после ответа юриста."""
kb = [
[
InlineKeyboardButton(text="📝 Задать следующий вопрос", callback_data="jurist_ask_next"),
InlineKeyboardButton(text="🚪 Выход из режима юриста", callback_data="jurist_exit")
]
]
return InlineKeyboardMarkup(inline_keyboard=kb)
async def determine_user_role(user_id: int) -> str:
"""Определяет роль пользователя в системе."""
if user_id == ADMIN_USER_ID:
return "admin"
try:
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
if user and (user.is_admin or user.has_ai_access):
return "lawyer"
except Exception as e:
log.warning(f"Failed to fetch user {user_id} role: {e}")
return "resident"
# === КОМАНДЫ АКТИВАЦИИ ===
@router.message(Command("jurist", "юрист", "вопрос", "help_law"))
@router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"]))
@router.message(F.text.casefold().startswith(("/юрист", "/jurist")))
async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
role = await determine_user_role(message.from_user.id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
return
# Проверяем, не задал ли пользователь вопрос прямо в команде: /юрист Какой срок ответа?
parts = (message.text or "").split(maxsplit=1)
if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"):
inline_question = parts[1].strip()
if len(inline_question) > 3:
status_msg = await message.answer("⏳ Изучаю документы, сметы, отчеты и решения дома № 37...", parse_mode='HTML')
result = await legal_engine.query(prompt=inline_question, role=role)
await status_msg.delete()
if result.get("success"):
await send_split_message(message, "", result["text"])
await state.set_state(LegalQuestionState.waiting_for_question)
else:
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
text = (
"⚖️ Юридический помощник (Дом № 37)\n\n"
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
"Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома."
)
await message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
@router.callback_query(F.data.in_(["admin_jurist", "jurist_ai", "main_jurist"]))
async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
await callback.answer()
role = await determine_user_role(callback.from_user.id)
if role not in ("admin", "lawyer"):
await callback.message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
return
text = (
"⚖️ Юридический помощник (Дом № 37)\n\n"
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
"Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома."
)
await callback.message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
# === КНОПКИ НАВИГАЦИИ ПОСЛЕ ОТВЕТА ЮРИСТА ===
@router.callback_query(F.data == "jurist_ask_next")
async def cb_jurist_ask_next(callback: CallbackQuery, state: FSMContext):
"""Пользователь хочет задать следующий вопрос юристу"""
await callback.answer()
role = await determine_user_role(callback.from_user.id)
if role not in ("admin", "lawyer"):
await callback.message.answer("🔒 Доступ ограничен.", parse_mode='HTML')
await state.clear()
return
await state.set_state(LegalQuestionState.waiting_for_question)
await callback.message.answer(
"⚖️ Режим юриста активен.\n\n"
"Напишите ваш следующий вопрос или отправьте голосовое сообщение:",
parse_mode='HTML'
)
@router.callback_query(F.data == "jurist_exit")
async def cb_jurist_exit(callback: CallbackQuery, state: FSMContext):
"""Выход из режима юриста с возвратом в главное меню"""
await callback.answer("Режим юриста завершён")
await state.clear()
user_id = callback.from_user.id
try:
from keyboards.inline import get_main_menu
from database.models import InitiativeGroup
import config
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True)
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
is_admin = (user.is_admin if user else False) or (user_id == config.ADMIN_USER_ID)
is_verified = user.verified if user else False
await callback.message.answer(
"🚪 Вы вышли из режима юриста.\n\nГлавное меню:",
reply_markup=get_main_menu(
verified=is_verified,
is_ig=is_ig,
is_admin=is_admin,
is_superadmin=(user_id == config.ADMIN_USER_ID)
),
parse_mode='HTML'
)
except Exception as e:
log.warning(f"Failed to show main menu on jurist exit: {e}")
await callback.message.answer("🚪 Вы вышли из режима юриста.", parse_mode='HTML')
# === ОБРАБОТКА ТЕКСТОВЫХ ВОПРОСОВ ===
@router.message(LegalQuestionState.waiting_for_question, F.text)
async def process_text_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
await state.clear()
return
question = message.text
status_msg = await message.answer(
"⏳ Изучаю документы, сметы, отчеты и решения дома № 37...",
parse_mode='HTML'
)
result = await legal_engine.query(prompt=question, role=role)
await status_msg.delete()
if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
# Защита от спама: если вопрос задан в групповом чате
if message.chat.type in ("group", "supergroup"):
# Ответ отправляется жильцу в ЛИЧКУ
try:
await bot.send_message(
chat_id=user_id,
text=format_telegram_html(result['text']),
parse_mode='HTML',
reply_markup=get_legal_nav_keyboard()
)
await message.reply(
"💬 Ответ отправлен вам в личные сообщения, чтобы не перегружать общий чат дома.",
parse_mode='HTML'
)
except Exception:
await message.reply(
"💬 Пожалуйста, откройте диалог с ботом (@lkmthreeseven_bot) и нажмите /start, чтобы получить ответ.",
parse_mode='HTML'
)
else:
# В личном диалоге отвечаем напрямую без лишних префиксов
await send_split_message(message, "", result["text"])
# Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика
await state.set_state(LegalQuestionState.waiting_for_question)
# === ОБРАБОТКА ГОЛОСОВЫХ ВОПРОСОВ (НАТИВНОЕ МУЛЬТИМОДАЛЬНОЕ РАСПОЗНАВАНИЕ) ===
@router.message(LegalQuestionState.waiting_for_question, F.voice)
@router.message(F.voice & F.chat.type == "private")
async def process_voice_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"Для использования юридического помощника обратитесь к @ktybdsq.",
parse_mode='HTML'
)
await state.clear()
return
status_msg = await message.answer(
"🎙 Распознаю аудио и сверяю с документами дома № 37...",
parse_mode='HTML'
)
voice_file_path = None
try:
# Скачиваем голосовой файл
file_info = await bot.get_file(message.voice.file_id)
voice_file_path = str(TEMP_VOICE_DIR / f"voice_{user_id}_{int(message.date.timestamp())}.ogg")
await bot.download_file(file_info.file_path, destination=voice_file_path)
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем поиск по базе дома)
result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path)
await status_msg.delete()
if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML')
return
# Отправляем чистый ответ без дублирования вопроса и без лишних плашек
await send_split_message(message, "", result["text"])
await state.set_state(LegalQuestionState.waiting_for_question)
except Exception as e:
log.error(f"Voice processing error: {e}")
await status_msg.edit_text(f"❌ Ошибка обработки голосового сообщения: {e}")
finally:
# Всегда зачищаем временный аудиофайл
if voice_file_path and os.path.exists(voice_file_path):
try: os.unlink(voice_file_path)
except: pass