feat(security): Zero-Spam PM redirect, RBAC menu filter, add Emergency Electrician service with card

This commit is contained in:
Admin 2026-10-22 21:54:21 +04:00
parent b9acc30bf4
commit d0606d6bde
9 changed files with 343 additions and 93 deletions

View file

@ -1,5 +1,11 @@
{ {
"emergency": [ "emergency": [
{
"name": "Аварийная служба (электрика)",
"phone": "54-69-29",
"description": "Круглосуточно при замыканиях в щитках, авариях электросетей и ЧП. Федеральный: 8 (8422) 54-69-29",
"priority": 1
},
{ {
"name": "Скорая помощь", "name": "Скорая помощь",
"phone": "103", "phone": "103",

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -19,6 +19,8 @@ services:
- ./exports:/app/exports - ./exports:/app/exports
- ./data:/app/data - ./data:/app/data
- ./handlers:/app/handlers - ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./services:/app/services - ./services:/app/services
- ../libs/swarmlib:/app/swarmlib:ro - ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
@ -52,6 +54,8 @@ services:
- ./exports:/app/exports - ./exports:/app/exports
- ./data:/app/data - ./data:/app/data
- ./handlers:/app/handlers - ./handlers:/app/handlers
- ./keyboards:/app/keyboards
- ./utils:/app/utils
- ./services:/app/services - ./services:/app/services
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw - /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro - /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro

View file

@ -134,13 +134,21 @@ async def cmd_legal_sync(message: Message):
@admin_only @admin_only
async def cmd_admin(message: Message): async def cmd_admin(message: Message):
"""Админ-меню (только в личке!)""" """Админ-меню (только в личке!)"""
# Проверяем что это личка # Если вызвано в группе — тихо удаляем из чата и отправляем админу в ЛС
if message.chat.type != 'private': if message.chat.type != 'private':
await message.answer( try:
'⚠️ <b>Админ-панель доступна только в личных сообщениях!</b>\n\n' await message.delete()
'Напишите боту в личку: /admin', except Exception:
parse_mode='HTML' pass
) try:
await message.bot.send_message(
chat_id=message.from_user.id,
text='🛡️ <b>Панель администратора</b>\n\nВыберите раздел:',
reply_markup=get_admin_main_menu(),
parse_mode='HTML'
)
except Exception:
pass
return return
await message.answer( await message.answer(

View file

@ -124,6 +124,22 @@ async def determine_user_role(user_id: int) -> str:
@router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"])) @router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"]))
@router.message(F.text.casefold().startswith(("/юрист", "/jurist"))) @router.message(F.text.casefold().startswith(("/юрист", "/jurist")))
async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot): async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
# Защита от спама в группе: в группе тихо удаляем и перенаправляем в ЛС
if message.chat.type in ('group', 'supergroup'):
try:
await message.delete()
except Exception:
pass
try:
await bot.send_message(
chat_id=message.from_user.id,
text="⚖️ <b>Юридический помощник ЖК РФ</b> доступен здесь в личных сообщениях.\nДля активации доступа обратитесь к @ktybdsq.",
parse_mode='HTML'
)
except Exception:
pass
return
role = await determine_user_role(message.from_user.id) role = await determine_user_role(message.from_user.id)
if role not in ("admin", "lawyer"): if role not in ("admin", "lawyer"):
await message.answer( await message.answer(

View file

@ -14,37 +14,26 @@ from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup from database.models import User, InitiativeGroup
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard
from services.ai_service import ai_service from services.ai_service import ai_service
from utils.redirect import respond_pm_or_redirect
import config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() router = Router()
async def safe_edit_or_reply(event, text: str, reply_markup=None, parse_mode='HTML'): async def safe_edit_or_reply(event, text: str, reply_markup=None, photo_path: str = None, parse_mode: str = 'HTML', start_param: str = 'start'):
""" """
Безопасное редактирование сообщения. Безопасный ответ: если вызван в группе — перенаправляет в личные сообщения пользователя
Если сообщение содержит медиа (фото и т.д.), мы удаляем его и отправляем новое. и тихо удаляет команду в группе, предотвращая спам.
""" """
if isinstance(event, CallbackQuery): await respond_pm_or_redirect(
await event.answer() event=event,
message = event.message text=text,
if (hasattr(message, 'photo') and message.photo) or \ reply_markup=reply_markup,
(hasattr(message, 'video') and message.video) or \ photo_path=photo_path,
(hasattr(message, 'document') and message.document) or \ parse_mode=parse_mode,
(hasattr(message, 'content_type') and message.content_type != 'text'): start_param=start_param
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(Command('cancel'), StateFilter('*'))
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*')) @router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
@ -59,9 +48,8 @@ async def global_cancel(message: Message, state: FSMContext):
@router.message(CommandStart()) @router.message(CommandStart())
async def cmd_start(message: Message): async def cmd_start(message: Message):
"""Команда /start""" """Команда /start: в ЛС открывает меню, в группе тихо перенаправляет в ЛС"""
from utils.ui_styles import fmt_msg, get_level_badge, get_user_level_key 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 user_id = message.from_user.id
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@ -88,7 +76,13 @@ async def cmd_start(message: Message):
f"Рад видеть тебя снова, {message.from_user.first_name}!\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') menu_kb = get_main_menu(
verified=user.verified if user else False,
is_ig=is_ig,
is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID) if user else (user_id == config.ADMIN_USER_ID),
is_superadmin=(user_id == config.ADMIN_USER_ID)
)
await safe_edit_or_reply(message, welcome_text, reply_markup=menu_kb, parse_mode='HTML', start_param='start')
@router.callback_query(F.data == 'main_menu') @router.callback_query(F.data == 'main_menu')
async def cb_main_menu(callback: CallbackQuery): async def cb_main_menu(callback: CallbackQuery):
@ -113,9 +107,9 @@ async def cb_main_menu(callback: CallbackQuery):
@router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны'])) @router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны']))
@router.callback_query(F.data == 'phones') @router.callback_query(F.data == 'phones')
async def cb_phones(event): async def cb_phones(event):
text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:' text = '📞 <b>ЭКСТРЕННЫЕ И ВАЖНЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
kb = get_phones_menu() kb = get_phones_menu()
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML') await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML', start_param='phones')
@router.message(Command('masters')) @router.message(Command('masters'))
@router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера'])) @router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера']))
@ -123,7 +117,6 @@ async def cmd_masters(message: Message):
"""Выдача списка мастеров по команде /masters или тексту 'мастера'""" """Выдача списка мастеров по команде /masters или тексту 'мастера'"""
from database.models import Service from database.models import Service
from keyboards.inline import get_main_menu from keyboards.inline import get_main_menu
from aiogram.types import FSInputFile
import os import os
user_id = message.from_user.id user_id = message.from_user.id
@ -142,7 +135,7 @@ async def cmd_masters(message: Message):
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin) menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if not services: if not services:
await message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML') await safe_edit_or_reply(message, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML', start_param='masters')
return return
text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n" text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n"
@ -153,10 +146,7 @@ async def cmd_masters(message: Message):
text += "\n" text += "\n"
photo_path = services[0].image_path if services[0].image_path else None photo_path = services[0].image_path if services[0].image_path else None
if photo_path and os.path.exists(photo_path): await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters')
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('/')) @router.message(F.text & ~F.text.startswith('/'))
async def chat_with_ai(message: Message): async def chat_with_ai(message: Message):
@ -186,10 +176,10 @@ async def chat_with_ai(message: Message):
async def cb_phone_category(callback: CallbackQuery): async def cb_phone_category(callback: CallbackQuery):
"""Выдача телефонов по категориям""" """Выдача телефонов по категориям"""
from database.models import Service from database.models import Service
from aiogram.types import FSInputFile
import os import os
category_map = { category_map = {
'phones_avariyka': 'avariyka',
'phones_emergency': 'emergency', 'phones_emergency': 'emergency',
'phones_utility': 'utility', 'phones_utility': 'utility',
'phones_police': 'police', 'phones_police': 'police',
@ -200,9 +190,6 @@ async def cb_phone_category(callback: CallbackQuery):
if not cat_code: return await callback.answer() if not cat_code: return await callback.answer()
async with AsyncSessionLocal() as session: 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_stmt = select(User).where(User.user_id == callback.from_user.id)
user = (await session.execute(user_stmt)).scalar_one_or_none() 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) ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == callback.from_user.id, InitiativeGroup.is_active == True)
@ -213,23 +200,43 @@ async def cb_phone_category(callback: CallbackQuery):
verified = user.verified if user else False 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) menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
if cat_code == 'avariyka':
text = (
"⚡ <b>АВАРИЙНАЯ СЛУЖБА (ЭЛЕКТРИКА И ЩИТКИ)</b>\n\n"
"📞 Городской: <code>54-69-29</code>\n"
"📱 Федеральный: <code>8 (8422) 54-69-29</code>\n\n"
"📝 <i>Круглосуточный вызов при замыканиях в щитках, обгорании проводки, авариях электросетей дома ЛКМ37.</i>"
)
photo_path = 'data/service_images/emergency_avariyka.png'
return await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
stmt = select(Service).where(Service.category == cat_code).order_by(Service.service_id.desc() if cat_code == 'emergency' else Service.name)
services = list((await session.execute(stmt)).scalars().all())
if not services: if not services:
return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb) return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb)
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n" category_titles = {
'emergency': '🚨 ЭКСТРЕННЫЕ СЛУЖБЫ',
'utility': '🔧 КОММУНАЛЬНЫЕ СЛУЖБЫ И ДИСПЕТЧЕР ЖСС',
'police': '👮 ПОЛИЦИЯ И УЧАСТКОВЫЙ',
'masters': '👷 МАСТЕРА'
}
title = category_titles.get(cat_code, f'📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}')
text = f"<b>{title}</b>\n\n"
for s in services: for s in services:
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n" text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
if s.description: text += f" └ 📝 <i>{s.description}</i>\n" if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
text += "\n" text += "\n"
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото) # Если есть фото у службы
photo_path = services[0].image_path if services[0].image_path else None photo_path = None
for s in services:
if s.image_path and os.path.exists(s.image_path):
photo_path = s.image_path
break
if photo_path and os.path.exists(photo_path): await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
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.message(Command('rules'))
@router.callback_query(F.data == 'rules') @router.callback_query(F.data == 'rules')

View file

@ -9,59 +9,62 @@ from utils.ui_styles import LEVEL_EMOJI
def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool = False, is_superadmin: bool = False) -> InlineKeyboardMarkup: def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool = False, is_superadmin: bool = False) -> InlineKeyboardMarkup:
""" """
Стабильное главное меню LKM37 (Grid 2xN) Стабильное главное меню LKM37 (Grid 2xN)
Строгая фильтрация по ролям:
- Гость: база + подтверждение квартиры
- Жилец: база + коммунальные сервисы дома
- Совет Дома: + Совет Дома
- Суперадмин: + Панель управления
""" """
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
# СТРОКА 1: БАЗА (ВСЕГДА ТУТ) # СТРОКА 1: БАЗА (ДОСТУПНА ВСЕМ)
builder.row( builder.row(
InlineKeyboardButton(text='📞 Важные телефоны', callback_data='phones'), InlineKeyboardButton(text='📞 Экстренные службы', callback_data='phones'),
InlineKeyboardButton(text='⚖️ Юрист (ЖК РФ)', callback_data='main_jurist') InlineKeyboardButton(text='⚖️ Юрист (ЖК РФ)', callback_data='main_jurist')
) )
builder.row(
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house')
)
# СТРОКА 2-3: ДЛЯ ВЕРИФИЦИРОВАННЫХ (ФИКСИРОВАННОЕ МЕСТО)
if verified: if verified:
# ДЛЯ ВЕРИФИЦИРОВАННЫХ ЖИЛЬЦОВ
builder.row( builder.row(
InlineKeyboardButton(text='⚠️ Отключения', callback_data='schedule'), InlineKeyboardButton(text='⚠️ Отключения', callback_data='schedule'),
InlineKeyboardButton(text='💳 Оплата ЖКХ', callback_data='payments') InlineKeyboardButton(text='💳 Оплата ЖКХ', callback_data='payments')
) )
builder.row( builder.row(
InlineKeyboardButton(text='🗳️ Голосования', callback_data='polls'), InlineKeyboardButton(text='🗳️ Опросы дома', callback_data='polls'),
InlineKeyboardButton(text='🗓️ События дома', callback_data='events') InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house')
) )
# СОВЕТ ДОМА (ИНИЦИАТИВНАЯ ГРУППА)
if is_ig:
builder.row(
InlineKeyboardButton(text='👥 Совет Дома', callback_data='ig_menu')
)
# АДМИНИСТРИРОВАНИЕ (ТОЛЬКО ДЛЯ АДМИНОВ / СУПЕРАДМИНА)
if is_superadmin:
builder.row(
InlineKeyboardButton(text='🛡️ Панель управления', callback_data='superadmin_menu')
)
elif is_admin:
builder.row(
InlineKeyboardButton(text='⚙️ Управление', callback_data='admin_menu')
)
# СЛУЖЕБНОЕ ВНИЗУ
builder.row( builder.row(
InlineKeyboardButton(text='📢 Объявления', callback_data='market'), InlineKeyboardButton(text='👤 Моя квартира', callback_data='my_profile'),
InlineKeyboardButton(text='🤝 Добрые соседи', callback_data='achievements') InlineKeyboardButton(text='❓ Помощь', callback_data='help')
) )
else: else:
# Для гостей кнопка верификации на видном месте # ДЛЯ НЕВЕРИФИЦИРОВАННЫХ (ГОСТЕЙ)
builder.row( builder.row(
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house'),
InlineKeyboardButton(text='🔑 Подтвердить квартиру', callback_data='start_verification') InlineKeyboardButton(text='🔑 Подтвердить квартиру', callback_data='start_verification')
) )
# СТРОКА 4: ИНИЦИАТИВНАЯ ГРУППА И АДМИН
if is_ig or is_admin:
buttons = []
if is_ig:
buttons.append(InlineKeyboardButton(text='👥 Совет Дома', callback_data='ig_menu'))
if is_admin:
buttons.append(InlineKeyboardButton(text='⚙️ Управление', callback_data='admin_menu'))
builder.row(*buttons)
# СТРОКА 5: СУПЕР-АДМИН (ПРЕДСЕДАТЕЛЬ)
if is_superadmin:
builder.row( builder.row(
InlineKeyboardButton(text='🛡️ Панель председателя', callback_data='superadmin_menu') InlineKeyboardButton(text='❓ Помощь / Связь с админом', callback_data='help')
) )
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ)
builder.row(
InlineKeyboardButton(text='❓ Справка', callback_data='help'),
InlineKeyboardButton(text='👤 Моя квартира', callback_data='my_profile')
)
return builder.as_markup() return builder.as_markup()
@ -160,15 +163,18 @@ def get_phones_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row( builder.row(
InlineKeyboardButton(text='🚨 Экстренные', callback_data='phones_emergency'), InlineKeyboardButton(text='⚡ Аварийка (54-69-29)', callback_data='phones_avariyka'),
InlineKeyboardButton(text='🔧 ЖКХ', callback_data='phones_utility'), InlineKeyboardButton(text='🚨 Все экстренные', callback_data='phones_emergency'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='👮 Полиция', callback_data='phones_police'), InlineKeyboardButton(text='🔧 Диспетчер ЖКХ', callback_data='phones_utility'),
InlineKeyboardButton(text='👷 Мастера', callback_data='phones_masters'), InlineKeyboardButton(text='👮 Полиция / Участковый', callback_data='phones_police'),
) )
builder.row( builder.row(
InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'), InlineKeyboardButton(text='👷 Мастера (сантехник и др.)', callback_data='phones_masters'),
)
builder.row(
InlineKeyboardButton(text='🔙 Назад в меню', callback_data='main_menu'),
) )
return builder.as_markup() return builder.as_markup()

42
main.py
View file

@ -50,14 +50,48 @@ async def on_startup(bot: Bot):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
scheduler.start() scheduler.start()
try: try:
from aiogram.types import BotCommand from aiogram.types import (
BotCommand, BotCommandScopeDefault,
BotCommandScopeAllGroupChats, BotCommandScopeAllPrivateChats,
BotCommandScopeChat
)
# 1. Для всех групповых чатов (публичный чат дома) - только базовая справка, без админок
await bot.set_my_commands([
BotCommand(command="phones", description="📞 Экстренные службы (в ЛС)"),
BotCommand(command="help", description="❓ Справка (в ЛС)")
], scope=BotCommandScopeAllGroupChats())
# 2. Для всех личных чатов с жильцами
await bot.set_my_commands([ await bot.set_my_commands([
BotCommand(command="start", description="Главное меню дома"), BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные службы"),
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"), BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
BotCommand(command="admin", description="🛡️ Админ-панель"),
BotCommand(command="phones", description="📞 Экстренные телефоны"),
BotCommand(command="help", description="❓ Помощь и навигация") BotCommand(command="help", description="❓ Помощь и навигация")
]) ], scope=BotCommandScopeAllPrivateChats())
# 3. Персонально для суперадмина дома (Александр Тырин)
if ADMIN_USER_ID:
try:
await bot.set_my_commands([
BotCommand(command="admin", description="🛡️ Админ-панель дома"),
BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные службы"),
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
BotCommand(command="stats", description="📊 Статистика дома"),
BotCommand(command="help", description="❓ Помощь и навигация")
], scope=BotCommandScopeChat(chat_id=ADMIN_USER_ID))
except Exception as admin_cmd_err:
logger.warning(f"Failed to set admin commands scope: {admin_cmd_err}")
# 4. Дефолтный fallback
await bot.set_my_commands([
BotCommand(command="start", description="Главное меню дома"),
BotCommand(command="phones", description="📞 Экстренные телефоны"),
BotCommand(command="help", description="❓ Помощь")
], scope=BotCommandScopeDefault())
logger.info("✅ Telegram BotCommand scopes successfully updated.")
except Exception as e: except Exception as e:
logger.warning(f"Failed to set bot commands: {e}") logger.warning(f"Failed to set bot commands: {e}")

169
utils/redirect.py Normal file
View file

@ -0,0 +1,169 @@
"""
Универсальный шлюз перенаправления в личные сообщения (Zero-Spam Redirect)
Все ответы на команды и меню отправляются строго в ЛС, не засоряя группу.
"""
import asyncio
import logging
import os
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
logger = logging.getLogger(__name__)
async def delete_after_delay(msg: Message, delay: int = 15):
"""Фоновое удаление временного уведомления через delay секунд"""
try:
await asyncio.sleep(delay)
await msg.delete()
except Exception:
pass
async def respond_pm_or_redirect(
event: Message | CallbackQuery,
text: str,
reply_markup=None,
photo_path: str = None,
parse_mode: str = 'HTML',
start_param: str = 'start'
):
"""
Отправляет контент пользователю строго в личные сообщения.
В групповом чате команда тихо удаляется, а пользователю отправляется карточка в ЛС.
Если бот не может написать в ЛС первым (пользователь не нажал /start),
выводится короткая временная плашка с кнопкой перехода в ЛС, удаляемая через 15 сек.
"""
bot = event.bot
# --- Обработка CallbackQuery ---
if isinstance(event, CallbackQuery):
message = event.message
# Если инлайн-кнопку нажали в группе
if message and message.chat.type in ('group', 'supergroup'):
user_id = event.from_user.id
try:
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=user_id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=user_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
await event.answer("Отправил вам в личные сообщения 📩", show_alert=False)
except Exception as e:
logger.info(f"Cannot PM user {user_id} on callback: {e}")
await event.answer(
"Пожалуйста, сначала откройте диалог с ботом в личке (@lkmthreeseven_bot)!",
show_alert=True
)
return
# Если инлайн-кнопку нажали в ЛС
await event.answer()
has_media = False
if message:
has_media = bool(
(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')
)
if has_media or (photo_path and os.path.exists(photo_path)):
if message:
try:
await message.delete()
except Exception:
pass
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=event.from_user.id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=event.from_user.id,
text=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:
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
return
# --- Обработка Message ---
if isinstance(event, Message):
user_id = event.from_user.id
# Если сообщение получено в личке — штатный ответ
if event.chat.type == 'private':
if photo_path and os.path.exists(photo_path):
await event.answer_photo(
FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
return
# Если сообщение получено в группе или супергруппе:
# 1. Пытаемся тихо удалить сообщение-команду жильца
try:
await event.delete()
except Exception:
pass
# 2. Отправляем карточку/меню пользователю в ЛС
sent_to_pm = False
try:
if photo_path and os.path.exists(photo_path):
await bot.send_photo(
chat_id=user_id,
photo=FSInputFile(photo_path),
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
else:
await bot.send_message(
chat_id=user_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
sent_to_pm = True
except Exception as e:
logger.info(f"Cannot PM user {user_id}: {e}")
# 3. Если в личку отправить не удалось (пользователь не начинал диалог с ботом)
if not sent_to_pm:
bot_user = await bot.get_me()
url = f"https://t.me/{bot_user.username}?start={start_param}"
kb = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="💬 Открыть в личке", url=url)
]])
user_mention = event.from_user.mention_html()
hint = await bot.send_message(
chat_id=event.chat.id,
text=f"👋 {user_mention}, чтобы не спамить в общий чат, перейдите в диалог с ботом:",
reply_markup=kb,
parse_mode='HTML'
)
asyncio.create_task(delete_after_delay(hint, 15))