"""
Пользовательские команды LKM37 REBORN
Доступны всем жильцам.
"""
import logging
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.filters import Command, CommandStart, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, update
from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard
from services.ai_service import ai_service
import config
logger = logging.getLogger(__name__)
router = Router()
async def safe_edit_or_reply(event, text: str, reply_markup=None, parse_mode='HTML'):
"""
Безопасное редактирование сообщения.
Если сообщение содержит медиа (фото и т.д.), мы удаляем его и отправляем новое.
"""
if isinstance(event, CallbackQuery):
await event.answer()
message = event.message
if (hasattr(message, 'photo') and message.photo) or \
(hasattr(message, 'video') and message.video) or \
(hasattr(message, 'document') and message.document) or \
(hasattr(message, 'content_type') and message.content_type != 'text'):
try:
await message.delete()
except Exception as e:
logger.warning(f"Не удалось удалить сообщение с медиа: {e}")
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
else:
try:
await message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
except Exception as e:
logger.warning(f"Ошибка edit_text: {e}. Отправляем новое сообщение.")
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
elif isinstance(event, Message):
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
@router.message(Command('cancel'), StateFilter('*'))
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
async def global_cancel(message: Message, state: FSMContext):
"""Глобальный выход из любого состояния FSM"""
current_state = await state.get_state()
if current_state:
await state.clear()
await message.answer("❌ Действие отменено.", reply_markup=get_main_menu())
else:
await message.answer("ℹ️ Нет активных действий для отмены.", reply_markup=get_main_menu())
@router.message(CommandStart())
async def cmd_start(message: Message):
"""Команда /start"""
from utils.ui_styles import fmt_msg, get_level_badge, get_user_level_key
if message.chat.type != 'private': return
user_id = message.from_user.id
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if not user:
user = User(user_id=user_id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name)
session.add(user)
await session.commit()
welcome_text = (f"{fmt_msg('Инициализация протокола связи...', 'system')}\n"
f"{fmt_msg('Обнаружен новый субъект.', 'system')}\n\n"
"Добро пожаловать в цифровую экосистему ЛКМ37.\n"
"Я твой проводник и помощник по дому.\n\n"
"🛡️ Для доступа ко всем функциям нажми кнопку верификации в меню.")
is_ig = False
else:
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
level_key = get_user_level_key(user_id, user.is_admin, user.verified, is_ig)
welcome_text = (f"{fmt_msg('Доступ разрешен.', 'system')}\n"
f"{fmt_msg(f'Статус: {get_level_badge(level_key)}', 'system')}\n\n"
f"Рад видеть тебя снова, {message.from_user.first_name}!\n"
"Чем могу помочь сегодня?")
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML')
@router.callback_query(F.data == 'main_menu')
async def cb_main_menu(callback: CallbackQuery):
"""Возврат в главное меню"""
user_id = callback.from_user.id
await callback.answer()
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
await safe_edit_or_reply(
callback,
"Главное меню:",
reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)),
parse_mode='HTML'
)
@router.message(Command('phones'))
@router.callback_query(F.data == 'phones')
async def cb_phones(event):
text = '📞 ЭКСТРЕННЫЕ ТЕЛЕФОНЫ\n\nВыберите категорию ниже:'
kb = get_phones_menu()
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML')
@router.message(Command('masters'))
@router.message(F.text.lower() == 'мастера')
async def cmd_masters(message: Message):
"""Выдача списка мастеров по команде /masters или тексту 'мастера'"""
from database.models import Service
from keyboards.inline import get_main_menu
from aiogram.types import FSInputFile
import os
user_id = message.from_user.id
async with AsyncSessionLocal() as session:
stmt = select(Service).where(Service.category == 'masters').order_by(Service.name)
services = list((await session.execute(stmt)).scalars().all())
user_stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(user_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 or user_id == config.ADMIN_USER_ID if user else False
is_superadmin = user_id == config.ADMIN_USER_ID
verified = user.verified if user else False
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if not services:
await message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML')
return
text = "📞 СПИСОК ТЕЛЕФОНОВ: МАСТЕРА\n\n"
for s in services:
text += f"🔹 {s.name}\n └ 📱 {s.phone}\n"
if s.description:
text += f" └ 📝 {s.description}\n"
text += "\n"
photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path):
await message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
else:
await message.answer(text, reply_markup=menu_kb, parse_mode='HTML')
@router.message(F.text & ~F.text.startswith('/'))
async def chat_with_ai(message: Message):
"""Обработка свободного общения с ИИ (только для верифицированных)"""
if message.chat.type != 'private': return
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == message.from_user.id)
user = (await session.execute(stmt)).scalar_one_or_none()
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == message.from_user.id, InitiativeGroup.is_active == True)
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
# Проверка прав: Админ ИЛИ ИГ ИЛИ персональный доступ
if not (user and (user.is_admin or is_ig or False)):
await message.answer("🔒 Доступ ограничен\n\nМой юридический ИИ-модуль доступен только участникам Инициативной группы и по персональному разрешению администратора.\n\nОбратитесь к @atyrin для получения доступа к базе знаний ГИС ЖКХ.", parse_mode='HTML')
return
user_info = {"apartment": user.apartment, "verified": user.verified, "role": "ИГ" if is_ig else "Жилец"}
# Отправляем индикатор печати
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
answer = await ai_service.answer_question(message.text, user_info)
await message.answer(answer, parse_mode='Markdown')
@router.callback_query(F.data.startswith('phones_'))
async def cb_phone_category(callback: CallbackQuery):
"""Выдача телефонов по категориям"""
from database.models import Service
from aiogram.types import FSInputFile
import os
category_map = {
'phones_emergency': 'emergency',
'phones_utility': 'utility',
'phones_police': 'police',
'phones_masters': 'masters'
}
cat_code = category_map.get(callback.data)
if not cat_code: return await callback.answer()
async with AsyncSessionLocal() as session:
stmt = select(Service).where(Service.category == cat_code).order_by(Service.name)
services = list((await session.execute(stmt)).scalars().all())
user_stmt = select(User).where(User.user_id == callback.from_user.id)
user = (await session.execute(user_stmt)).scalar_one_or_none()
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == callback.from_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 or callback.from_user.id == config.ADMIN_USER_ID if user else False
is_superadmin = callback.from_user.id == config.ADMIN_USER_ID
verified = user.verified if user else False
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if not services:
return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb)
text = f"📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}\n\n"
for s in services:
text += f"🔹 {s.name}\n └ 📱 {s.phone}\n"
if s.description: text += f" └ 📝 {s.description}\n"
text += "\n"
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path):
await callback.message.delete()
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
else:
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, parse_mode='HTML')
@router.message(Command('rules'))
@router.callback_query(F.data == 'rules')
async def cb_rules(event):
text = "📜 ПРАВИЛА ЧАТА ЛКМ37\n\n1. Уважение к соседям\n2. Никакого спама и рекламы\n3. Мат запрещен\n4. Обсуждаем только дела дома."
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.message(Command('about'))
@router.callback_query(F.data == 'about_house')
async def cb_about(event):
text = ("🏢 О НАШЕМ ДОМЕ (ЛКМ37)\n\n"
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
"🏗️ Тип дома: Многоквартирный жилой дом\n"
"🛠️ Управляющая компания: (укажите вашу УК)\n\n"
"Этот бот создан для объединения жильцов и быстрого решения проблем.")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.message(Command('profile'))
@router.callback_query(F.data == 'my_profile')
async def cb_profile(event):
from utils.ui_styles import get_level_badge, get_user_level_key
user_id = event.from_user.id
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
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
badge = get_level_badge(get_user_level_key(user_id, user.is_admin, user.verified, is_ig))
text = (f"👤 ТВОЙ ПРОФИЛЬ\n\n"
f"ID: {user_id}\n"
f"Статус: {badge}\n"
f"Квартира: {user.apartment or 'Не указана'}\n"
f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n"
f"Рейтинг: {user.rating} 🏆")
kb = get_profile_keyboard(user.meter_reminders_enabled if user else True)
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML')
@router.callback_query(F.data == "toggle_meter_reminders")
async def cb_toggle_meter_reminders(callback: CallbackQuery):
user_id = callback.from_user.id
from utils.ui_styles import get_level_badge, get_user_level_key
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
new_val = not user.meter_reminders_enabled
user.meter_reminders_enabled = new_val
await session.commit()
await callback.answer(
f"Напоминания о счетчиках {'включены' if new_val else 'отключены'}!"
)
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
badge = get_level_badge(get_user_level_key(user_id, user.is_admin, user.verified, is_ig))
text = (f"👤 ТВОЙ ПРОФИЛЬ\n\n"
f"ID: {user_id}\n"
f"Статус: {badge}\n"
f"Квартира: {user.apartment or 'Не указана'}\n"
f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n"
f"Рейтинг: {user.rating} 🏆")
kb = get_profile_keyboard(new_val)
await safe_edit_or_reply(callback, text, reply_markup=kb, parse_mode='HTML')
@router.callback_query(F.data == "edit_location")
async def edit_location_start(callback: CallbackQuery):
await safe_edit_or_reply(callback, "Введите ваш подъезд и этаж через пробел\nНапример: '3 5' (3 подъезд, 5 этаж)")
@router.message(F.text.regexp(r"^\d+\s+\d+$"))
async def process_location(message: Message):
try:
staircase, floor = map(int, message.text.split())
async with AsyncSessionLocal() as session:
await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
await session.commit()
user = await session.get(User, message.from_user.id)
meter_enabled = user.meter_reminders_enabled if user else True
await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}", reply_markup=get_profile_keyboard(meter_enabled), parse_mode='HTML')
except Exception as e:
await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
# Класс состояний обратной связи
class HelpFeedbackState(StatesGroup):
waiting_for_message = State()
waiting_for_reply = State()
@router.message(Command('help'))
@router.callback_query(F.data == 'help')
async def cb_help(event, state: FSMContext):
await state.clear()
text = ("❓ СПРАВКА И ПОДДЕРЖКА\n\n"
"• Используйте кнопки в главном меню для навигации.\n"
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
"Список команд:\n"
"/start — Главное меню\n"
"/phones — Телефоны\n"
"/about — О доме\n"
"/profile — Твой профиль\n"
"/rules — Правила чата\n\n"
"Вы можете отправить сообщение Инициативной группе / Администрации дома, нажав кнопку ниже.")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="✍️ Написать администрации", callback_data="help_feedback"))
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.callback_query(F.data == "help_feedback")
async def start_help_feedback(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await state.set_state(HelpFeedbackState.waiting_for_message)
text = ("✍️ ОБРАТНАЯ СВЯЗЬ\n\n"
"Пожалуйста, введите текст вашего сообщения для администрации дома LKM37.\n"
"Вы также можете прикрепить одно фото или документ к вашему сообщению.\n\n"
"Для отмены нажмите кнопку ниже или введите /cancel.")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="❌ Отмена", callback_data="main_menu"))
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.message(HelpFeedbackState.waiting_for_message)
async def process_help_feedback(message: Message, state: FSMContext):
if message.text and message.text.startswith('/'):
if message.text.lower() in ['/cancel', '/start']:
await state.clear()
await message.answer("❌ Отправка сообщения отменена.", reply_markup=get_main_menu())
return
user_id = message.from_user.id
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
sender_name = message.from_user.full_name or f"user{user_id}"
username_str = f" (@{message.from_user.username})" if message.from_user.username else ""
apt_str = user.apartment or "Не указана" if user else "Не указана"
admin_text = (f"📬 НОВОЕ ОБРАЩЕНИЕ ОТ ЖИЛЬЦА LKM37!\n\n"
f"👤 Отправитель: {sender_name}{username_str}\n"
f"🏢 Квартира: {apt_str}\n"
f"🆔 ID: {user_id}\n\n"
f"📝 Сообщение:\n{message.text or message.caption or '—'}")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text="✍️ Ответить жильцу", callback_data=f"help_reply_{user_id}"))
try:
if message.photo:
await message.bot.send_photo(
chat_id=config.ADMIN_USER_ID,
photo=message.photo[-1].file_id,
caption=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
elif message.document:
await message.bot.send_document(
chat_id=config.ADMIN_USER_ID,
document=message.document.file_id,
caption=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
else:
await message.bot.send_message(
chat_id=config.ADMIN_USER_ID,
text=admin_text,
reply_markup=kb.as_markup(),
parse_mode='HTML'
)
await message.answer("✅ Ваше сообщение успешно отправлено!\n\nАдминистрация ответит вам в ближайшее время.", reply_markup=get_main_menu(), parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при отправке фидбека админу: {e}")
await message.answer("❌ Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже.", reply_markup=get_main_menu())
await state.clear()
@router.callback_query(F.data.startswith("help_reply_"))
async def start_help_reply(callback: CallbackQuery, state: FSMContext):
if callback.from_user.id != config.ADMIN_USER_ID:
await callback.answer("🔒 Доступ только для суперадмина.", show_alert=True)
return
target_user_id = int(callback.data.split("_")[2])
await state.update_data(target_user_id=target_user_id)
await state.set_state(HelpFeedbackState.waiting_for_reply)
await callback.answer()
await callback.message.answer(f"✍️ Введите ответ для пользователя {target_user_id}:\n\nДля отмены пришлите /cancel.", parse_mode='HTML')
@router.message(HelpFeedbackState.waiting_for_reply)
async def process_help_reply(message: Message, state: FSMContext):
if message.from_user.id != config.ADMIN_USER_ID:
return
if message.text and message.text.lower() in ['/cancel', 'отмена']:
await state.clear()
await message.answer("❌ Ответ отменен.", reply_markup=get_main_menu())
return
data = await state.get_data()
target_user_id = data.get("target_user_id")
if not target_user_id:
await message.answer("❌ Ошибка: пользователь для ответа не найден в контексте.")
await state.clear()
return
reply_text = (f"📩 ОТВЕТ АДМИНИСТРАЦИИ LKM37:\n\n"
f"{message.text}")
try:
await message.bot.send_message(
chat_id=target_user_id,
text=reply_text,
parse_mode='HTML'
)
await message.answer(f"✅ Ответ успешно отправлен пользователю {target_user_id}.", parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при отправке ответа пользователю {target_user_id}: {e}")
await message.answer(f"❌ Не удалось отправить сообщение пользователю. Ошибка: {e}")
await state.clear()