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))