feat(security): Zero-Spam PM redirect, RBAC menu filter, add Emergency Electrician service with card
This commit is contained in:
parent
b9acc30bf4
commit
d0606d6bde
9 changed files with 343 additions and 93 deletions
|
|
@ -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",
|
||||||
|
|
@ -94,4 +100,4 @@
|
||||||
"verified_by_admin": false
|
"verified_by_admin": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
BIN
data/service_images/emergency_avariyka.png
Normal file
BIN
data/service_images/emergency_avariyka.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 photo_path and os.path.exists(photo_path):
|
if s.image_path and os.path.exists(s.image_path):
|
||||||
await callback.message.delete()
|
photo_path = s.image_path
|
||||||
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
|
break
|
||||||
else:
|
|
||||||
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, parse_mode='HTML')
|
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
|
||||||
|
|
||||||
@router.message(Command('rules'))
|
@router.message(Command('rules'))
|
||||||
@router.callback_query(F.data == 'rules')
|
@router.callback_query(F.data == 'rules')
|
||||||
|
|
|
||||||
|
|
@ -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
42
main.py
|
|
@ -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
169
utils/redirect.py
Normal 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))
|
||||||
Loading…
Reference in a new issue