433 lines
23 KiB
Python
433 lines
23 KiB
Python
"""
|
||
Пользовательские команды 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"
|
||
"Добро пожаловать в цифровую экосистему <b>ЛКМ37</b>.\n"
|
||
"Я твой проводник и помощник по дому.\n\n"
|
||
"🛡️ <b>Для доступа ко всем функциям нажми кнопку верификации в меню.</b>")
|
||
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 = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
|
||
kb = get_phones_menu()
|
||
await safe_edit_or_reply(event, text, reply_markup=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("🔒 <b>Доступ ограничен</b>\n\nМой юридический ИИ-модуль доступен только участникам <b>Инициативной группы</b> и по персональному разрешению администратора.\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"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n"
|
||
for s in services:
|
||
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
|
||
if s.description: text += f" └ 📝 <i>{s.description}</i>\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 = "<b>📜 ПРАВИЛА ЧАТА ЛКМ37</b>\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 = ("<b>🏢 О НАШЕМ ДОМЕ (ЛКМ37)</b>\n\n"
|
||
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
|
||
"🏗️ Тип дома: Многоквартирный жилой дом\n"
|
||
"🛠️ Управляющая компания: (укажите вашу УК)\n\n"
|
||
"<i>Этот бот создан для объединения жильцов и быстрого решения проблем.</i>")
|
||
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"👤 <b>ТВОЙ ПРОФИЛЬ</b>\n\n"
|
||
f"ID: <code>{user_id}</code>\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"👤 <b>ТВОЙ ПРОФИЛЬ</b>\n\n"
|
||
f"ID: <code>{user_id}</code>\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<i>Например: '3 5' (3 подъезд, 5 этаж)</i>")
|
||
|
||
@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 = ("<b>❓ СПРАВКА И ПОДДЕРЖКА</b>\n\n"
|
||
"• Используйте кнопки в главном меню для навигации.\n"
|
||
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
|
||
"<b>Список команд:</b>\n"
|
||
"/start — Главное меню\n"
|
||
"/phones — Телефоны\n"
|
||
"/about — О доме\n"
|
||
"/profile — Твой профиль\n"
|
||
"/rules — Правила чата\n\n"
|
||
"<i>Вы можете отправить сообщение Инициативной группе / Администрации дома, нажав кнопку ниже.</i>")
|
||
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 = ("✍️ <b>ОБРАТНАЯ СВЯЗЬ</b>\n\n"
|
||
"Пожалуйста, введите текст вашего сообщения для администрации дома LKM37.\n"
|
||
"Вы также можете прикрепить одно фото или документ к вашему сообщению.\n\n"
|
||
"<i>Для отмены нажмите кнопку ниже или введите /cancel.</i>")
|
||
|
||
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"📬 <b>НОВОЕ ОБРАЩЕНИЕ ОТ ЖИЛЬЦА LKM37!</b>\n\n"
|
||
f"👤 Отправитель: <b>{sender_name}</b>{username_str}\n"
|
||
f"🏢 Квартира: <b>{apt_str}</b>\n"
|
||
f"🆔 ID: <code>{user_id}</code>\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("✅ <b>Ваше сообщение успешно отправлено!</b>\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"✍️ Введите ответ для пользователя <code>{target_user_id}</code>:\n\n<i>Для отмены пришлите /cancel.</i>", 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"📩 <b>ОТВЕТ АДМИНИСТРАЦИИ LKM37:</b>\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"✅ Ответ успешно отправлен пользователю <code>{target_user_id}</code>.", parse_mode='HTML')
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при отправке ответа пользователю {target_user_id}: {e}")
|
||
await message.answer(f"❌ Не удалось отправить сообщение пользователю. Ошибка: {e}")
|
||
|
||
await state.clear()
|