diff --git a/data/phones.json b/data/phones.json index bd2fb76..332ab74 100644 --- a/data/phones.json +++ b/data/phones.json @@ -1,5 +1,11 @@ { "emergency": [ + { + "name": "Аварийная служба (электрика)", + "phone": "54-69-29", + "description": "Круглосуточно при замыканиях в щитках, авариях электросетей и ЧП. Федеральный: 8 (8422) 54-69-29", + "priority": 1 + }, { "name": "Скорая помощь", "phone": "103", @@ -94,4 +100,4 @@ "verified_by_admin": false } ] -} +} \ No newline at end of file diff --git a/data/service_images/emergency_avariyka.png b/data/service_images/emergency_avariyka.png new file mode 100644 index 0000000..536b531 Binary files /dev/null and b/data/service_images/emergency_avariyka.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 0c4ba3c..235c0da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - ./exports:/app/exports - ./data:/app/data - ./handlers:/app/handlers + - ./keyboards:/app/keyboards + - ./utils:/app/utils - ./services:/app/services - ../libs/swarmlib:/app/swarmlib:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro @@ -52,6 +54,8 @@ services: - ./exports:/app/exports - ./data:/app/data - ./handlers:/app/handlers + - ./keyboards:/app/keyboards + - ./utils:/app/utils - ./services:/app/services - /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw - /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro diff --git a/handlers/admin.py b/handlers/admin.py index b73b93c..ef065d7 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -134,13 +134,21 @@ async def cmd_legal_sync(message: Message): @admin_only async def cmd_admin(message: Message): """Админ-меню (только в личке!)""" - # Проверяем что это личка + # Если вызвано в группе — тихо удаляем из чата и отправляем админу в ЛС if message.chat.type != 'private': - await message.answer( - '⚠️ Админ-панель доступна только в личных сообщениях!\n\n' - 'Напишите боту в личку: /admin', - parse_mode='HTML' - ) + try: + await message.delete() + except Exception: + pass + try: + await message.bot.send_message( + chat_id=message.from_user.id, + text='🛡️ Панель администратора\n\nВыберите раздел:', + reply_markup=get_admin_main_menu(), + parse_mode='HTML' + ) + except Exception: + pass return await message.answer( diff --git a/handlers/legal_advisor.py b/handlers/legal_advisor.py index 4e76336..8c207a6 100644 --- a/handlers/legal_advisor.py +++ b/handlers/legal_advisor.py @@ -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().startswith(("/юрист", "/jurist"))) 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="⚖️ Юридический помощник ЖК РФ доступен здесь в личных сообщениях.\nДля активации доступа обратитесь к @ktybdsq.", + parse_mode='HTML' + ) + except Exception: + pass + return + role = await determine_user_role(message.from_user.id) if role not in ("admin", "lawyer"): await message.answer( diff --git a/handlers/users.py b/handlers/users.py index 2bfaa5c..6c2672a 100644 --- a/handlers/users.py +++ b/handlers/users.py @@ -14,37 +14,26 @@ 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 +from utils.redirect import respond_pm_or_redirect import config logger = logging.getLogger(__name__) 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 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) + await respond_pm_or_redirect( + event=event, + text=text, + reply_markup=reply_markup, + photo_path=photo_path, + parse_mode=parse_mode, + start_param=start_param + ) @router.message(Command('cancel'), StateFilter('*')) @router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*')) @@ -59,9 +48,8 @@ async def global_cancel(message: Message, state: FSMContext): @router.message(CommandStart()) async def cmd_start(message: Message): - """Команда /start""" + """Команда /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: @@ -88,7 +76,13 @@ async def cmd_start(message: Message): 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') async def cb_main_menu(callback: CallbackQuery): @@ -113,9 +107,9 @@ async def cb_main_menu(callback: CallbackQuery): @router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны'])) @router.callback_query(F.data == 'phones') async def cb_phones(event): - text = '📞 ЭКСТРЕННЫЕ ТЕЛЕФОНЫ\n\nВыберите категорию ниже:' + text = '📞 ЭКСТРЕННЫЕ И ВАЖНЫЕ ТЕЛЕФОНЫ\n\nВыберите категорию ниже:' 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(F.text.in_(['👷 Мастера', 'Мастера', 'мастера'])) @@ -123,7 +117,6 @@ 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 @@ -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) 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 text = "📞 СПИСОК ТЕЛЕФОНОВ: МАСТЕРА\n\n" @@ -153,10 +146,7 @@ async def cmd_masters(message: Message): 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') + await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters') @router.message(F.text & ~F.text.startswith('/')) 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): """Выдача телефонов по категориям""" from database.models import Service - from aiogram.types import FSInputFile import os category_map = { + 'phones_avariyka': 'avariyka', 'phones_emergency': 'emergency', 'phones_utility': 'utility', 'phones_police': 'police', @@ -200,9 +190,6 @@ async def cb_phone_category(callback: CallbackQuery): 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) @@ -213,23 +200,43 @@ async def cb_phone_category(callback: CallbackQuery): 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 cat_code == 'avariyka': + text = ( + "⚡ АВАРИЙНАЯ СЛУЖБА (ЭЛЕКТРИКА И ЩИТКИ)\n\n" + "📞 Городской: 54-69-29\n" + "📱 Федеральный: 8 (8422) 54-69-29\n\n" + "📝 Круглосуточный вызов при замыканиях в щитках, обгорании проводки, авариях электросетей дома ЛКМ37." + ) + 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: return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb) - text = f"📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}\n\n" + category_titles = { + 'emergency': '🚨 ЭКСТРЕННЫЕ СЛУЖБЫ', + 'utility': '🔧 КОММУНАЛЬНЫЕ СЛУЖБЫ И ДИСПЕТЧЕР ЖСС', + 'police': '👮 ПОЛИЦИЯ И УЧАСТКОВЫЙ', + 'masters': '👷 МАСТЕРА' + } + title = category_titles.get(cat_code, f'📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}') + text = f"{title}\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') + # Если есть фото у службы + photo_path = None + for s in services: + if s.image_path and os.path.exists(s.image_path): + photo_path = s.image_path + break + + await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML') @router.message(Command('rules')) @router.callback_query(F.data == 'rules') diff --git a/keyboards/inline.py b/keyboards/inline.py index bc64e49..968544a 100644 --- a/keyboards/inline.py +++ b/keyboards/inline.py @@ -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: """ Стабильное главное меню LKM37 (Grid 2xN) + Строгая фильтрация по ролям: + - Гость: база + подтверждение квартиры + - Жилец: база + коммунальные сервисы дома + - Совет Дома: + Совет Дома + - Суперадмин: + Панель управления """ builder = InlineKeyboardBuilder() - # СТРОКА 1: БАЗА (ВСЕГДА ТУТ) + # СТРОКА 1: БАЗА (ДОСТУПНА ВСЕМ) builder.row( - InlineKeyboardButton(text='📞 Важные телефоны', callback_data='phones'), + InlineKeyboardButton(text='📞 Экстренные службы', callback_data='phones'), InlineKeyboardButton(text='⚖️ Юрист (ЖК РФ)', callback_data='main_jurist') ) - builder.row( - InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house') - ) - # СТРОКА 2-3: ДЛЯ ВЕРИФИЦИРОВАННЫХ (ФИКСИРОВАННОЕ МЕСТО) if verified: + # ДЛЯ ВЕРИФИЦИРОВАННЫХ ЖИЛЬЦОВ builder.row( InlineKeyboardButton(text='⚠️ Отключения', callback_data='schedule'), InlineKeyboardButton(text='💳 Оплата ЖКХ', callback_data='payments') ) builder.row( - InlineKeyboardButton(text='🗳️ Голосования', callback_data='polls'), - InlineKeyboardButton(text='🗓️ События дома', callback_data='events') + InlineKeyboardButton(text='🗳️ Опросы дома', callback_data='polls'), + 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( - InlineKeyboardButton(text='📢 Объявления', callback_data='market'), - InlineKeyboardButton(text='🤝 Добрые соседи', callback_data='achievements') + InlineKeyboardButton(text='👤 Моя квартира', callback_data='my_profile'), + InlineKeyboardButton(text='❓ Помощь', callback_data='help') ) else: - # Для гостей кнопка верификации на видном месте + # ДЛЯ НЕВЕРИФИЦИРОВАННЫХ (ГОСТЕЙ) builder.row( + InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house'), 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( - 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() @@ -160,15 +163,18 @@ def get_phones_menu() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.row( - InlineKeyboardButton(text='🚨 Экстренные', callback_data='phones_emergency'), - InlineKeyboardButton(text='🔧 ЖКХ', callback_data='phones_utility'), + InlineKeyboardButton(text='⚡ Аварийка (54-69-29)', callback_data='phones_avariyka'), + InlineKeyboardButton(text='🚨 Все экстренные', callback_data='phones_emergency'), ) builder.row( - InlineKeyboardButton(text='👮 Полиция', callback_data='phones_police'), - InlineKeyboardButton(text='👷 Мастера', callback_data='phones_masters'), + InlineKeyboardButton(text='🔧 Диспетчер ЖКХ', callback_data='phones_utility'), + InlineKeyboardButton(text='👮 Полиция / Участковый', callback_data='phones_police'), ) 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() diff --git a/main.py b/main.py index 6ad6378..26ab3c3 100644 --- a/main.py +++ b/main.py @@ -50,14 +50,48 @@ async def on_startup(bot: Bot): scheduler = Scheduler(bot) scheduler.start() 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([ BotCommand(command="start", description="Главное меню дома"), + BotCommand(command="phones", description="📞 Экстренные службы"), BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"), - BotCommand(command="admin", description="🛡️ Админ-панель"), - BotCommand(command="phones", 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: logger.warning(f"Failed to set bot commands: {e}") diff --git a/utils/redirect.py b/utils/redirect.py new file mode 100644 index 0000000..c7752a4 --- /dev/null +++ b/utils/redirect.py @@ -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))