🔥 ОБЪЯВЛЕНИЯ: • Срок действия: 7 → 14 дней 🎯 ИНИЦИАТИВНАЯ ГРУППА: • Добавлена кнопка 'Рассылка ИГ' в меню • Команда /ig_broadcast для членов ИГ • Поддержка текста + фото + файлов • Настройка лимита в веб-панели ⚙️ ВЕБ-ПАНЕЛЬ: • Новая страница /settings • Настройка лимита рассылки ИГ (секунды) • Кнопки статистики фильтруют пользователей • Индикатор активного фильтра 🎨 UI: • Исправлен <font> → emoji (Telegram не поддерживает) • Цветные заголовки через emoji 📁 ФАЙЛЫ: • handlers/ads.py: 14 дней • handlers/initiative_group.py: рассылка ИГ • web/app.py: API настроек • web/templates/settings.html: страница настроек • utils/ui_styles.py: без <font> тегов • database: settings table Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""
|
||
Доска объявлений — куплю, продам, отдам, найду
|
||
Для всех верифицированных пользователей
|
||
"""
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from aiogram import Router, F
|
||
from aiogram.types import Message, CallbackQuery
|
||
from aiogram.filters import Command, StateFilter
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.fsm.context import FSMContext
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
from database.db import AsyncSessionLocal
|
||
from database.models import Ad, User
|
||
from utils.decorators import admin_only
|
||
from keyboards.inline import get_ads_category_menu, get_ads_item_menu
|
||
import config
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = Router()
|
||
|
||
|
||
# =============================================================================
|
||
# FSM для создания объявления
|
||
# =============================================================================
|
||
|
||
class AdState(StatesGroup):
|
||
waiting_for_category = State()
|
||
waiting_for_title = State()
|
||
waiting_for_description = State()
|
||
waiting_for_price = State()
|
||
waiting_for_phone = State()
|
||
|
||
|
||
# =============================================================================
|
||
# СОЗДАНИЕ ОБЪЯВЛЕНИЯ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ad'))
|
||
@router.message(Command('объявление'))
|
||
async def cmd_ad_create(message: Message, state: FSMContext):
|
||
"""
|
||
Создать объявление
|
||
Использование: /ad или /объявление
|
||
"""
|
||
# Проверяем верификацию
|
||
async with AsyncSessionLocal() as session:
|
||
user = await session.get(User, message.from_user.id)
|
||
|
||
if not user or not user.verified:
|
||
await message.answer(
|
||
'❌ <b>Только для верифицированных жильцов!</b>\n\n'
|
||
'Сначала пройдите верификацию по номеру квартиры.\n'
|
||
'Напишите боту в личку номер вашей квартиры.',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
await message.answer(
|
||
'📢 <b>Создание объявления</b>\n\n'
|
||
'Выберите категорию:\n\n'
|
||
'1️⃣ 💰 Продам\n'
|
||
'2️⃣ 🛒 Куплю\n'
|
||
'3️⃣ 🎁 Отдам\n'
|
||
'4️⃣ 😿 Потеряно\n'
|
||
'5️⃣ 😺 Нашлось\n'
|
||
'6️⃣ 🔧 Услуга\n\n'
|
||
'Или отправьте номер категории (1-6):',
|
||
reply_markup=get_ads_category_menu(),
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_category)
|
||
|
||
|
||
@router.callback_query(F.data.startswith('ads_category_'))
|
||
async def callback_ads_category(callback: CallbackQuery, state: FSMContext):
|
||
"""Выбор категории объявления"""
|
||
category_map = {
|
||
'ads_category_sell': ('sell', '💰 Продам'),
|
||
'ads_category_buy': ('buy', '🛒 Куплю'),
|
||
'ads_category_give': ('give', '🎁 Отдам'),
|
||
'ads_category_lost': ('lost', '😿 Потеряно'),
|
||
'ads_category_found': ('found', '😺 Нашлось'),
|
||
'ads_category_service': ('service', '🔧 Услуга'),
|
||
}
|
||
|
||
cat_key, cat_name = category_map.get(callback.data, ('sell', '💰 Продам'))
|
||
await state.update_data(category=cat_key, category_name=cat_name)
|
||
|
||
await callback.message.answer(
|
||
f'✅ Категория: {cat_name}\n\n'
|
||
'Теперь отправьте название объявления:\n'
|
||
'<i>Например: "Продам диван"</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_title)
|
||
await callback.answer()
|
||
|
||
|
||
@router.message(AdState.waiting_for_category)
|
||
async def process_ads_category(message: Message, state: FSMContext):
|
||
"""Выбор категории текстом"""
|
||
category_map = {
|
||
'1': ('sell', '💰 Продам'),
|
||
'2': ('buy', '🛒 Куплю'),
|
||
'3': ('give', '🎁 Отдам'),
|
||
'4': ('lost', '😿 Потеряно'),
|
||
'5': ('found', '😺 Нашлось'),
|
||
'6': ('service', '🔧 Услуга'),
|
||
}
|
||
|
||
text = message.text.strip()
|
||
if text in category_map:
|
||
cat_key, cat_name = category_map[text]
|
||
await state.update_data(category=cat_key, category_name=cat_name)
|
||
|
||
await message.answer(
|
||
f'✅ Категория: {cat_name}\n\n'
|
||
'Теперь отправьте название объявления:\n'
|
||
'<i>Например: "Продам диван"</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_title)
|
||
else:
|
||
await message.answer('❌ Неверная категория. Выберите из меню или отправьте номер 1-6:')
|
||
|
||
|
||
@router.message(AdState.waiting_for_title)
|
||
async def process_ads_title(message: Message, state: FSMContext):
|
||
"""Получили название объявления"""
|
||
title = message.text
|
||
await state.update_data(title=title)
|
||
|
||
await message.answer(
|
||
f'✅ Название: {title}\n\n'
|
||
'Теперь отправьте описание:\n'
|
||
'<i>Например: "Диван угловой, состояние хорошее, самовывоз"</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_description)
|
||
|
||
|
||
@router.message(AdState.waiting_for_description)
|
||
async def process_ads_description(message: Message, state: FSMContext):
|
||
"""Получили описание"""
|
||
description = message.text
|
||
await state.update_data(description=description)
|
||
|
||
await message.answer(
|
||
f'✅ Описание принято\n\n'
|
||
'Укажите цену (в рублях) или отправьте "0" если бесплатно:\n'
|
||
'<i>Например: 5000</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_price)
|
||
|
||
|
||
@router.message(AdState.waiting_for_price)
|
||
async def process_ads_price(message: Message, state: FSMContext):
|
||
"""Получили цену"""
|
||
text = message.text.strip()
|
||
|
||
try:
|
||
price = int(text)
|
||
if price < 0:
|
||
raise ValueError
|
||
except ValueError:
|
||
await message.answer('❌ Неверный формат цены. Отправьте число или "0":')
|
||
return
|
||
|
||
await state.update_data(price=price if price > 0 else None)
|
||
|
||
await message.answer(
|
||
f'✅ Цена: {price if price > 0 else "Бесплатно"}\n\n'
|
||
'Укажите контактный телефон или отправьте "пропустить":\n'
|
||
'<i>Например: +7 (999) 123-45-67</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state(AdState.waiting_for_phone)
|
||
|
||
|
||
@router.message(AdState.waiting_for_phone)
|
||
async def process_ads_phone(message: Message, state: FSMContext):
|
||
"""Получили телефон или пропустили"""
|
||
text = message.text.strip()
|
||
|
||
if text.lower() == 'пропустить':
|
||
phone = None
|
||
else:
|
||
phone = text
|
||
|
||
await state.update_data(phone=phone)
|
||
|
||
# Сохраняем объявление в БД
|
||
data = await state.get_data()
|
||
expires_at = datetime.utcnow() + timedelta(days=14) # 14 дней вместо 7
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ad = Ad(
|
||
user_id=message.from_user.id,
|
||
category=data['category'],
|
||
title=data['title'],
|
||
description=data['description'],
|
||
price=data.get('price'),
|
||
phone=phone,
|
||
expires_at=expires_at,
|
||
)
|
||
session.add(ad)
|
||
await session.commit()
|
||
await session.refresh(ad)
|
||
|
||
# Формируем объявление для публикации
|
||
emoji = ad.get_category_emoji()
|
||
cat_name = ad.get_category_name()
|
||
price_text = f'{ad.price} ₽' if ad.price else 'Бесплатно'
|
||
|
||
ad_text = (
|
||
f'{emoji} <b>{cat_name}: {ad.title}</b>\n\n'
|
||
f'📝 {ad.description}\n\n'
|
||
f'💰 Цена: {price_text}\n'
|
||
)
|
||
|
||
if phone:
|
||
ad_text += f'📞 Телефон: {phone}\n'
|
||
|
||
ad_text += f'\n⏳ Действует до: {expires_at.strftime("%d.%m.%Y")}\n'
|
||
ad_text += f'🔖 ID: {ad.ad_id}'
|
||
|
||
# Отправляем в чат в тему "Важные объявления"
|
||
try:
|
||
sent_message = await message.bot.send_message(
|
||
chat_id=config.ADMIN_CHAT_ID,
|
||
message_thread_id=config.get_topic_id('ads'), # Тема "Важные объявления"
|
||
text=ad_text,
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
# Сохраняем message_id
|
||
ad.message_id = sent_message.message_id
|
||
await session.commit()
|
||
|
||
except Exception as e:
|
||
logger.error(f'Не удалось опубликовать объявление: {e}')
|
||
|
||
await message.answer(
|
||
f'✅ <b>Объявление опубликовано!</b>\n\n'
|
||
f'ID: {ad.ad_id}\n'
|
||
f'Действует до: {expires_at.strftime("%d.%m.%Y")}\n\n'
|
||
f'Для просмотра: /ads',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.clear()
|
||
|
||
|
||
# =============================================================================
|
||
# ПРОСМОТР ОБЪЯВЛЕНИЙ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ads'))
|
||
@router.message(Command('доска'))
|
||
async def cmd_ads_list(message: Message):
|
||
"""Показать все активные объявления"""
|
||
async with AsyncSessionLocal() as session:
|
||
# Получаем активные объявления
|
||
stmt = (
|
||
select(Ad)
|
||
.where(Ad.is_active == True)
|
||
.where(Ad.expires_at > datetime.utcnow())
|
||
.order_by(Ad.created_at.desc())
|
||
.limit(20)
|
||
)
|
||
result = await session.execute(stmt)
|
||
ads = list(result.scalars().all())
|
||
|
||
if not ads:
|
||
await message.answer('✅ Нет активных объявлений')
|
||
return
|
||
|
||
text = '📢 <b>Доска объявлений</b>\n\n'
|
||
|
||
for i, ad in enumerate(ads, 1):
|
||
emoji = ad.get_category_emoji()
|
||
cat_name = ad.get_category_name()
|
||
price_text = f'{ad.price} ₽' if ad.price else 'Бесплатно'
|
||
|
||
text += f'{i}. {emoji} <b>{cat_name}: {ad.title[:30]}</b>\n'
|
||
text += f' └─ Цена: {price_text}\n'
|
||
text += f' └─ Действует до: {ad.expires_at.strftime("%d.%m.%Y")}\n'
|
||
text += f' └─ ID: {ad.ad_id}\n\n'
|
||
|
||
text += '💡 Для создания: /ad'
|
||
|
||
await message.answer(text, parse_mode='HTML')
|
||
|
||
|
||
# =============================================================================
|
||
# УДАЛЕНИЕ ОБЪЯВЛЕНИЯ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ad_del'))
|
||
async def cmd_ad_del(message: Message):
|
||
"""
|
||
Удалить своё объявление
|
||
Использование: /ad_del <ID>
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer(
|
||
'❌ <b>Использование:</b>\n'
|
||
'<code>/ad_del <ID></code>\n\n'
|
||
'ID можно узнать из /ads',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
try:
|
||
ad_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ad = await session.get(Ad, ad_id)
|
||
|
||
if not ad:
|
||
await message.answer(f'❌ Объвление {ad_id} не найдено')
|
||
return
|
||
|
||
# Проверяем что это своё объявление или админ
|
||
if ad.user_id != message.from_user.id and not config.is_admin(message.from_user.id):
|
||
await message.answer('❌ Можно удалять только свои объявления!')
|
||
return
|
||
|
||
ad.is_active = False
|
||
await session.commit()
|
||
|
||
# Пытаемся удалить сообщение из чата
|
||
if ad.message_id:
|
||
try:
|
||
await message.bot.delete_message(config.ADMIN_CHAT_ID, ad.message_id)
|
||
except Exception:
|
||
pass
|
||
|
||
await message.answer(f'✅ Объявление "{ad.title}" удалено')
|
||
|
||
|
||
# =============================================================================
|
||
# МОДЕРАЦИЯ (для админов)
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ads_moderate'))
|
||
@admin_only
|
||
async def cmd_ads_moderate(message: Message):
|
||
"""Показать объявления на модерации"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = (
|
||
select(Ad)
|
||
.where(Ad.is_moderated == False)
|
||
.where(Ad.is_active == True)
|
||
.order_by(Ad.created_at.desc())
|
||
)
|
||
result = await session.execute(stmt)
|
||
ads = list(result.scalars().all())
|
||
|
||
if not ads:
|
||
await message.answer('✅ Нет объявлений на модерации')
|
||
return
|
||
|
||
text = '📋 <b>Объявления на модерации</b>\n\n'
|
||
for ad in ads:
|
||
user = await session.get(User, ad.user_id)
|
||
text += f'• ID {ad.ad_id}: {ad.title} (от {user.full_name if user else "?"})\n'
|
||
|
||
text += '\n💡 Для подтверждения: /ad_approve <ID>'
|
||
|
||
await message.answer(text)
|
||
|
||
|
||
@router.message(Command('ad_approve'))
|
||
@admin_only
|
||
async def cmd_ad_approve(message: Message):
|
||
"""
|
||
Подтвердить объявление
|
||
Использование: /ad_approve <ID>
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer('❌ Использование: /ad_approve <ID>')
|
||
return
|
||
|
||
try:
|
||
ad_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ad = await session.get(Ad, ad_id)
|
||
|
||
if not ad:
|
||
await message.answer(f'❌ Объвление {ad_id} не найдено')
|
||
return
|
||
|
||
ad.is_moderated = True
|
||
await session.commit()
|
||
|
||
await message.answer(f'✅ Объявление {ad_id} подтверждено')
|
||
|
||
|
||
# =============================================================================
|
||
# АВТО-УДАЛЕНИЕ СТАРЫХ ОБЪЯВЛЕНИЙ
|
||
# =============================================================================
|
||
|
||
async def cleanup_expired_ads():
|
||
"""Удалить просроченные объявления"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = (
|
||
select(Ad)
|
||
.where(Ad.is_active == True)
|
||
.where(Ad.expires_at <= datetime.utcnow())
|
||
)
|
||
result = await session.execute(stmt)
|
||
ads = list(result.scalars().all())
|
||
|
||
for ad in ads:
|
||
ad.is_active = False
|
||
# Пытаемся удалить сообщение
|
||
if ad.message_id:
|
||
try:
|
||
from aiogram import Bot
|
||
# Bot будет передан из scheduler
|
||
except Exception:
|
||
pass
|
||
|
||
await session.commit()
|
||
|
||
if ads:
|
||
logger.info(f'Удалено {len(ads)} просроченных объявлений')
|