""" Пользовательские команды 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, get_weather_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() # ========================================== # ОБРАБОТЧИКИ МЕНЮ ПОГОДЫ # ========================================== @router.callback_query(F.data == 'weather') async def cb_weather_menu(callback: CallbackQuery): """Показ меню прогноза погоды""" text = ( "🌦️ Погодный информатор ЛКМ37\n\n" "Выберите интересующий вас прогноз погоды для Ульяновска:" ) await safe_edit_or_reply(callback, text, reply_markup=get_weather_keyboard()) @router.callback_query(F.data == 'weather_today') async def cb_weather_today(callback: CallbackQuery): """Показ подробного прогноза погоды на сегодня""" from services.weather import WeatherService text = await WeatherService.get_today_forecast() # Добавим кнопку возврата в меню погоды builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather')) await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup()) @router.callback_query(F.data == 'weather_3days') async def cb_weather_3days(callback: CallbackQuery): """Показ прогноза погоды на 3 дня""" from services.weather import WeatherService text = await WeatherService.get_3day_forecast() builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather')) await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup()) @router.callback_query(F.data == 'weather_7days') async def cb_weather_7days(callback: CallbackQuery): """Показ прогноза погоды на неделю""" from services.weather import WeatherService text = await WeatherService.get_7day_forecast() builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text='🔙 В меню погоды', callback_data='weather')) await safe_edit_or_reply(callback, text, reply_markup=builder.as_markup())