🚨 fix: Критические исправления рассылок

- Глобальный синглтон бота (bot_instance.py) — работает из любого процесса
- Прямые HTTP запросы в send_smart_broadcast — нет ошибки Timeout context
- Исправлен joinedload в InitiativeGroupService
- Удалён дубликат route /broadcast
- Удалена страница /broadcasts — всё в одном месте с табами
- Удалён /api/broadcast/send — дублировал /api/broadcast/create
- Добавлен dry_run режим для тестирования без отправки
- Двойное подтверждение перед реальной рассылкой
- Добавлен файл КРИТИЧЕСКИЕ-ПРАВИЛА.md

Инцидент: тестовая рассылка ушла реальным пользователям

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Admin 2026-04-12 17:57:18 +04:00
parent fa8c1d53d9
commit 07d7a51f10
8 changed files with 627 additions and 805 deletions

52
bot_instance.py Normal file
View file

@ -0,0 +1,52 @@
"""
Глобальный экземпляр бота доступен из любого процесса
"""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.client.session.aiohttp import AiohttpSession
from config import BOT_TOKEN, get_proxy_url, PROXY_TYPE, PROXY_HOST, PROXY_PORT
import logging
logger = logging.getLogger(__name__)
# Глобальный синглтон
bot: Bot | None = None
def get_bot() -> Bot:
"""Получить экземпляр бота (создаёт если ещё не создан)"""
global bot
if bot is None:
bot = _create_bot()
return bot
def _create_bot() -> Bot:
"""Создать бота с настройкой прокси"""
proxy_url = get_proxy_url()
if proxy_url:
logger.info(f'🔒 Создание бота с прокси: {PROXY_TYPE}://{PROXY_HOST}:{PROXY_PORT}')
aiogram_session = AiohttpSession(proxy=proxy_url)
return Bot(
token=BOT_TOKEN,
session=aiogram_session,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
else:
logger.info('🔓 Создание бота без прокси')
return Bot(
token=BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
async def close_bot():
"""Закрыть сессию бота"""
global bot
if bot and bot.session:
await bot.session.close()
logger.info('🔒 Сессия бота закрыта')

View file

@ -156,21 +156,27 @@ async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None) -> bool: async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None) -> bool:
""" """
Отправить умную рассылку с кнопкой "Прочитал" Отправить умную рассылку с кнопкой "Прочитал"
Использует прямые HTTP запросы для работы из любого event loop
Args: Args:
bot: Bot instance bot: Bot instance
text: Текст рассылки text: Текст рассылки
recipients: 'all_and_chat', 'all_verified', 'ig_only' recipients: 'all_and_chat', 'all_verified', 'ig_only'
photo_file_id: ID фото (опционально) photo_file_id: ID фото (опционально)
Returns: Returns:
bool: Успешность отправки bool: Успешность отправки
""" """
import aiohttp
from config import BOT_TOKEN, get_proxy_url
try: try:
# Inline клавиатура с кнопкой "Прочитал" # Inline клавиатура с кнопкой "Прочитал"
keyboard = InlineKeyboardMarkup(inline_keyboard=[ reply_markup = {
[InlineKeyboardButton(text="✅ Прочитал", callback_data="broadcast_read")] "inline_keyboard": [
]) [{"text": "✅ Прочитал", "callback_data": "broadcast_read"}]
]
}
# Получаем список пользователей # Получаем список пользователей
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@ -187,25 +193,40 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
result = await session.execute(stmt) result = await session.execute(stmt)
users = list(result.scalars().all()) users = list(result.scalars().all())
# URL для Telegram API
bot_token = BOT_TOKEN
proxy_url = get_proxy_url()
# Отправляем в чат первым # Отправляем в чат первым
chat_message_id = None chat_message_id = None
try: try:
if photo_file_id: async with aiohttp.ClientSession() as http_session:
msg = await bot.send_photo( if photo_file_id:
ADMIN_CHAT_ID, url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
photo_file_id, data = aiohttp.FormData()
caption=f"📢 <b>Объявление от администрации</b>\n\n{text}", data.add_field('chat_id', str(ADMIN_CHAT_ID))
reply_markup=keyboard, data.add_field('photo', photo_file_id)
parse_mode='HTML' data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
) data.add_field('reply_markup', json.dumps(reply_markup))
else: data.add_field('parse_mode', 'HTML')
msg = await bot.send_message(
ADMIN_CHAT_ID, async with http_session.post(url, data=data, proxy=proxy_url) as resp:
f"📢 <b>Объявление от администрации</b>\n\n{text}", result_data = await resp.json()
reply_markup=keyboard, if result_data.get('ok'):
parse_mode='HTML' chat_message_id = result_data['result']['message_id']
) else:
chat_message_id = msg.message_id url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
'chat_id': ADMIN_CHAT_ID,
'text': f"📢 <b>Объявление от администрации</b>\n\n{text}",
'reply_markup': json.dumps(reply_markup),
'parse_mode': 'HTML'
}
async with http_session.post(url, json=payload, proxy=proxy_url) as resp:
result_data = await resp.json()
if result_data.get('ok'):
chat_message_id = result_data['result']['message_id']
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}") logger.error(f"Ошибка отправки в чат: {e}")
@ -231,40 +252,63 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
success_count = 0 success_count = 0
error_count = 0 error_count = 0
for user in users: # Используем один HTTP сеанс для всех запросов
try: async with aiohttp.ClientSession() as http_session:
if photo_file_id: for user in users:
await bot.send_photo( try:
user.user_id, if photo_file_id:
photo_file_id, url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
caption=f"📢 <b>Объявление от администрации</b>\n\n{text}", data = aiohttp.FormData()
reply_markup=keyboard, data.add_field('chat_id', str(user.user_id))
parse_mode='HTML' data.add_field('photo', photo_file_id)
) data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
else: data.add_field('reply_markup', json.dumps(reply_markup))
await bot.send_message( data.add_field('parse_mode', 'HTML')
user.user_id,
f"📢 <b>Объявление от администрации</b>\n\n{text}", async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
reply_markup=keyboard, result_data = await resp.json()
parse_mode='HTML' if result_data.get('ok'):
) success_count += 1
success_count += 1 else:
except Exception as e: error_count += 1
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") else:
error_count += 1 url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
'chat_id': user.user_id,
'text': f"📢 <b>Объявление от администрации</b>\n\n{text}",
'reply_markup': json.dumps(reply_markup),
'parse_mode': 'HTML'
}
async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
result_data = await resp.json()
if result_data.get('ok'):
success_count += 1
else:
error_count += 1
except Exception as e:
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
error_count += 1
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}") logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}")
# Уведомляем админа # Уведомляем админа через HTTP
await bot.send_message( try:
ADMIN_USER_ID, async with aiohttp.ClientSession() as http_session:
f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n" url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
f"✅ Доставлено: {success_count}\n" payload = {
f"❌ Ошибки: {error_count}\n" 'chat_id': ADMIN_USER_ID,
f"📈 Прочитали: 0/{len(users)}\n\n" 'text': f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n"
f"Статистика обновляется в реальном времени в веб-панели", f"✅ Доставлено: {success_count}\n"
parse_mode='HTML' f"❌ Ошибки: {error_count}\n"
) f"📈 Прочитали: 0/{len(users)}\n\n"
f"Статистика обновляется в реальном времени в веб-панели",
'parse_mode': 'HTML'
}
async with http_session.post(url, json=payload, proxy=proxy_url) as resp:
await resp.json()
except Exception as e:
logger.error(f"Ошибка уведомления админа: {e}")
return True return True

38
main.py
View file

@ -178,29 +178,8 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_bot_with_proxy() -> Bot: # Функция create_bot_with_proxy перенесена в bot_instance.py
"""Создание бота с настройкой прокси""" # Теперь используется глобальный экземпляр через get_bot()
proxy_url = get_proxy_url()
if proxy_url:
logger.info(f'🔒 Использование прокси: {PROXY_TYPE}://{PROXY_HOST}:{PROXY_PORT}')
# Создаём сессию aiogram с прокси
aiogram_session = AiohttpSession(proxy=proxy_url)
bot = Bot(
token=BOT_TOKEN,
session=aiogram_session,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
else:
logger.info('🔓 Работа без прокси')
bot = Bot(
token=BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
return bot
async def on_startup(bot: Bot): async def on_startup(bot: Bot):
@ -250,16 +229,17 @@ async def on_shutdown(bot: Bot):
) )
except Exception: except Exception:
pass pass
# Закрываем сессию # Закрываем сессию через глобальный модуль
await bot.session.close() from bot_instance import close_bot
logger.info('🔒 Сессия закрыта') await close_bot()
async def main(): async def main():
"""Основная функция""" """Основная функция"""
# Создаём бота с прокси # Используем глобальный экземпляр бота
bot = create_bot_with_proxy() from bot_instance import get_bot, close_bot
bot = get_bot()
# Создаём диспетчер с FSM storage (для состояний) # Создаём диспетчер с FSM storage (для состояний)
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())

View file

@ -71,15 +71,21 @@ class InitiativeGroupService:
Returns: список участников Returns: список участников
""" """
from database.models import InitiativeGroup, User from database.models import InitiativeGroup, User
from sqlalchemy.orm import joinedload
# Используем joinedload для загрузки связи User
stmt = (
select(InitiativeGroup)
.options(joinedload(InitiativeGroup.user))
.join(User, InitiativeGroup.user_id == User.user_id)
)
stmt = select(InitiativeGroup).join(User, InitiativeGroup.user_id == User.user_id)
if active_only: if active_only:
stmt = stmt.where(InitiativeGroup.is_active == True) stmt = stmt.where(InitiativeGroup.is_active == True)
stmt = stmt.order_by(InitiativeGroup.joined_at) stmt = stmt.order_by(InitiativeGroup.joined_at)
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
members = list(result.scalars().all()) members = list(result.scalars().unique().all())
return [ return [
{ {

View file

@ -520,164 +520,44 @@ async def api_delete_ad(ad_id: int, username: str = Depends(get_current_admin)):
# ============================================================================ # ============================================================================
# РАССЫЛКИ # РАССЫЛКИ — единая страница создания и статистики
# ============================================================================ # ============================================================================
@app.get("/broadcast", response_class=HTMLResponse) @app.get("/broadcast", response_class=HTMLResponse)
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)): async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
"""Рассылки""" """Рассылки — создание и статистика"""
return templates.TemplateResponse("broadcast.html", { from database.models import Broadcast, BroadcastRead
"request": request, "username": username from sqlalchemy import func
})
@app.get("/broadcast", response_class=HTMLResponse)
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница рассылок"""
return templates.TemplateResponse("broadcast.html", {
"request": request, "username": username
})
@app.post("/api/broadcast/send")
async def api_send_broadcast(
text: str = Form(...),
photo: UploadFile = File(None),
recipients: str = Form("all_and_chat"),
username: str = Depends(get_current_admin)
):
"""Отправить рассылку"""
import aiohttp
from pathlib import Path
from services.initiative_group import InitiativeGroupService
# Логируем начало рассылки
logger.info(f"📬 НАЧАЛО РАССЫЛКИ: получатели={recipients}, текст_len={len(text) if text else 0}")
photo_id = None
# Если есть картинка — загружаем
if photo and photo.filename:
temp_path = Path("data") / f"broadcast_{photo.filename}"
with open(temp_path, "wb") as f:
content = await photo.read()
f.write(content)
try:
async with aiohttp.ClientSession() as session:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto"
data = aiohttp.FormData()
data.add_field('chat_id', config.ADMIN_USER_ID)
data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename)
data.add_field('caption', 'Test')
async with session.post(url, data=data) as resp:
result = await resp.json()
if result.get('ok'):
photo_id = result['result']['photo'][-1]['file_id']
except Exception as e:
logger.error(f"Ошибка загрузки фото: {e}")
# Получаем получателей
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
if recipients == 'ig_only': # История рассылок
# Только ИГ stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50)
ig_service = InitiativeGroupService(session) result = await session.execute(stmt)
ig_user_ids = await ig_service.get_member_ids(active_only=True) broadcasts = list(result.scalars().all())
stmt = select(User).where(User.user_id.in_(ig_user_ids))
users = list((await session.execute(stmt)).scalars().all())
elif recipients == 'all_verified':
# Все верифицированные
stmt = select(User).where(User.verified == True)
users = list((await session.execute(stmt)).scalars().all())
else:
# Все верифицированные (по умолчанию)
stmt = select(User).where(User.verified == True)
users = list((await session.execute(stmt)).scalars().all())
success_count = 0 # Считаем прочтения для каждой
error_count = 0 broadcasts_data = []
chat_sent = False for b in broadcasts:
read_count = (await session.execute(
select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id)
)).scalar() or 0
async with aiohttp.ClientSession() as http_session: broadcasts_data.append({
# Отправка пользователям 'id': b.id,
for user in users: 'text': b.text[:100] + '...' if len(b.text) > 100 else b.text,
try: 'sent_at': b.sent_at.strftime('%d.%m.%Y %H:%M') if b.sent_at else '-',
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/" 'total_sent': b.total_sent,
if photo_id: 'read_count': read_count,
url += "sendPhoto" 'unread_count': b.total_sent - read_count,
data = aiohttp.FormData() 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
data.add_field('chat_id', user.user_id) 'is_reminder_sent': b.is_reminder_sent,
data.add_field('photo', photo_id) 'broadcast_type': b.broadcast_type or 'regular',
data.add_field('caption', f"📢 Объявление от администрации\n\n{text}")
async with http_session.post(url, data=data, timeout=30) as resp:
if resp.status == 200:
success_count += 1
else:
url += "sendMessage"
data = aiohttp.FormData()
data.add_field('chat_id', user.user_id)
data.add_field('text', f"📢 Объявление от администрации\n\n{text}")
data.add_field('parse_mode', 'HTML')
async with http_session.post(url, data=data, timeout=30) as resp:
if resp.status == 200:
success_count += 1
except Exception as e:
error_count += 1
# Отправка в чат (если нужно)
if recipients in ['all_and_chat', 'chat_only']:
try:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/"
if photo_id:
url += "sendPhoto"
data = aiohttp.FormData()
data.add_field('chat_id', config.ADMIN_CHAT_ID)
data.add_field('photo', photo_id)
data.add_field('caption', f"📢 Объявление от администрации\n\n{text}")
async with http_session.post(url, data=data, timeout=30) as resp:
if resp.status == 200:
chat_sent = True
else:
url += "sendMessage"
data = aiohttp.FormData()
data.add_field('chat_id', config.ADMIN_CHAT_ID)
data.add_field('text', f"📢 Объявление от администрации\n\n{text}")
data.add_field('parse_mode', 'HTML')
async with http_session.post(url, data=data, timeout=30) as resp:
if resp.status == 200:
chat_sent = True
except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}")
# Логируем в базу
try:
from sqlalchemy import text
async with AsyncSessionLocal() as session:
await session.execute(text('''
INSERT INTO activity_log (user_id, action_type, action_data, recipients_count)
VALUES (:user_id, 'broadcast', :data, :count)
'''), {
"user_id": config.ADMIN_USER_ID,
"data": f"recipients={recipients}, text_len={len(text)}",
"count": success_count
}) })
await session.commit()
except Exception as e:
logger.error(f"Ошибка логирования рассылки: {e}")
# Логируем результат return templates.TemplateResponse("broadcast.html", {
logger.info(f"✅ РАССЫЛКА ЗАВЕРШЕНА: успешно={success_count}, ошибок={error_count}, получатели={recipients}") "request": request,
"username": username,
return JSONResponse({ "broadcasts": broadcasts_data,
"success": True,
"message": f"Отправлено: {success_count} (в чат: {'' if chat_sent else ''})",
"details": {
"users_sent": success_count,
"errors": error_count,
"chat_sent": chat_sent,
"recipients": recipients
}
}) })
@ -1089,56 +969,15 @@ def run_web_server(host: str = "0.0.0.0", port: int = 8000):
uvicorn.run(app, host=host, port=port) uvicorn.run(app, host=host, port=port)
# ============================================================================
# V3.1 - УМНЫЕ РАССЫЛКИ
# ============================================================================
@app.get("/broadcasts", response_class=HTMLResponse)
async def broadcasts_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница умных рассылок"""
from database.models import Broadcast, BroadcastRead
from sqlalchemy import func, desc
async with AsyncSessionLocal() as session:
# Все рассылки
stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50)
result = await session.execute(stmt)
broadcasts = list(result.scalars().all())
# Считаем прочтения для каждой
broadcasts_data = []
for b in broadcasts:
read_count = (await session.execute(
select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id)
)).scalar() or 0
broadcasts_data.append({
'id': b.id,
'text': b.text[:100] + '...' if len(b.text) > 100 else b.text,
'sent_at': b.sent_at.strftime('%d.%m.%Y %H:%M') if b.sent_at else '-',
'total_sent': b.total_sent,
'read_count': read_count,
'unread_count': b.total_sent - read_count,
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
'is_reminder_sent': b.is_reminder_sent,
'broadcast_type': b.broadcast_type,
})
return templates.TemplateResponse("broadcasts.html", {
"request": request,
"username": username,
"broadcasts": broadcasts_data,
})
@app.post("/api/broadcast/send_reminder/{broadcast_id}") @app.post("/api/broadcast/send_reminder/{broadcast_id}")
async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)): async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)):
"""Отправить напоминание непрочитавшим""" """Отправить напоминание непрочитавшим"""
from handlers.smart_broadcast import send_reminder_to_unread from handlers.smart_broadcast import send_reminder_to_unread
from bot_instance import get_bot
# Получаем bot из main
import main bot = get_bot()
sent_count = await send_reminder_to_unread(main.bot, broadcast_id) sent_count = await send_reminder_to_unread(bot, broadcast_id)
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
@ -1188,53 +1027,125 @@ async def api_create_broadcast(
photo: UploadFile = File(None), photo: UploadFile = File(None),
recipients: str = Form("all_and_chat"), recipients: str = Form("all_and_chat"),
has_read_button: bool = Form(True), has_read_button: bool = Form(True),
dry_run: bool = Form(False), # Режим теста БЕЗ отправки
username: str = Depends(get_current_admin) username: str = Depends(get_current_admin)
): ):
"""Создать и отправить рассылку из веб-панели""" """Создать и отправить рассылку из веб-панели"""
# dry_run = тестирование без реальной отправки
if dry_run:
from database.models import User
from services.initiative_group import InitiativeGroupService
async with AsyncSessionLocal() as session:
if recipients == 'ig_only':
ig_service = InitiativeGroupService(session)
ig_user_ids = await ig_service.get_member_ids(active_only=True)
stmt = select(User).where(User.user_id.in_(ig_user_ids))
elif recipients == 'all_verified':
stmt = select(User).where(User.verified == True)
else:
stmt = select(User).where(User.verified == True)
result = await session.execute(stmt)
users = list(result.scalars().all())
logger.info(f"🧪 DRY RUN рассылки: получатели={recipients}, пользователей={len(users)}")
return JSONResponse({
"success": True,
"dry_run": True,
"message": f"Тестовый режим. Рассылка получила бы {len(users)} пользователей",
"recipients_count": len(users)
})
import aiohttp import aiohttp
from database.models import Broadcast from database.models import Broadcast
from datetime import datetime from datetime import datetime
photo_file_id = None photo_file_id = None
# Если есть фото - загружаем # Если есть фото - загружаем через Telegram Bot API
if photo and photo.filename: if photo and photo.filename:
try: try:
# Сохраняем временно
temp_path = Path("data") / f"broadcast_{photo.filename}" temp_path = Path("data") / f"broadcast_{photo.filename}"
content = await photo.read()
with open(temp_path, "wb") as f: with open(temp_path, "wb") as f:
content = await photo.read()
f.write(content) f.write(content)
# Загружаем фото через Telegram API для получения file_id
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto"
# Если бот использует прокси - добавляем его в aiohttp
proxy = config.get_proxy_url()
data = aiohttp.FormData() data = aiohttp.FormData()
data.add_field('chat_id', config.ADMIN_USER_ID) data.add_field('chat_id', config.ADMIN_USER_ID)
data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename)
data.add_field('caption', 'Test')
async with session.post(url, data=data) as resp: # Открываем файл и сразу передаём в FormData
result = await resp.json() with open(temp_path, 'rb') as photo_file:
if result.get('ok'): data.add_field('photo', photo_file, filename=photo.filename)
photo_file_id = result['result']['photo'][-1]['file_id'] data.add_field('caption', 'broadcast_test')
data.add_field('parse_mode', 'HTML')
async with session.post(url, data=data, proxy=proxy) as resp:
text = await resp.text()
# Проверяем что ответ успешный
if resp.status != 200:
logger.error(f"Ошибка загрузки фото: HTTP {resp.status}, ответ: {text}")
raise Exception(f"Telegram API вернул ошибку: HTTP {resp.status}")
# Пытаемся распарсить JSON
try:
result = json.loads(text)
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON от Telegram API: {e}, ответ: {text}")
raise Exception(f"Telegram API вернул не-JSON ответ: {text[:100]}")
if result.get('ok'):
photo_file_id = result['result']['photo'][-1]['file_id']
logger.info(f"✅ Фото загружено, file_id: {photo_file_id}")
else:
error_msg = result.get('description', 'Неизвестная ошибка')
logger.error(f"Ошибка загрузки фото: {error_msg}")
raise Exception(f"Ошибка Telegram API: {error_msg}")
# Удаляем временный файл
if temp_path.exists():
temp_path.unlink()
except Exception as e: except Exception as e:
logger.error(f"Ошибка загрузки фото: {e}") logger.error(f"❌ Ошибка загрузки фото для рассылки: {e}")
# Не падаем, просто продолжаем без фото
# Получаем бота photo_file_id = None
import main
bot = main.bot
# Получаем бота из глобального экземпляра
from bot_instance import get_bot
bot = get_bot()
# Отправляем рассылку # Отправляем рассылку
from handlers.smart_broadcast import send_smart_broadcast try:
success = await send_smart_broadcast(bot, text, recipients, photo_file_id) from handlers.smart_broadcast import send_smart_broadcast
success = await send_smart_broadcast(bot, text, recipients, photo_file_id)
if success:
if success:
return JSONResponse({
"success": True,
"message": f"Рассылка отправлена получателям: {recipients}"
})
else:
return JSONResponse({
"error": "Ошибка при отправке рассылки. Проверьте логи бота."
}, status_code=500)
except Exception as e:
logger.error(f"❌ Критическая ошибка при отправке рассылки: {e}")
import traceback
traceback.print_exc()
return JSONResponse({ return JSONResponse({
"success": True, "error": f"Критическая ошибка: {str(e)}"
"message": f"Рассылка отправлена получателям: {recipients}"
})
else:
return JSONResponse({
"error": "Ошибка при отправке рассылки"
}, status_code=500) }, status_code=500)

View file

@ -16,162 +16,216 @@
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; } .broadcast-type-option.active { border-color: #667eea; background: #eef2ff; }
.broadcast-type-option h4 { margin-bottom: 10px; } .broadcast-type-option h4 { margin-bottom: 10px; }
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; } .broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; }
.stats-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-top: 30px; } .nav-tabs .nav-link { color: #495057; }
.stat-mini { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; } .nav-tabs .nav-link.active { font-weight: bold; color: #667eea; }
.stat-mini h3 { font-size: 0.85em; color: #666; margin-bottom: 5px; } .broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-mini .value { font-size: 1.8em; font-weight: bold; color: #667eea; }
.recent-broadcasts { margin-top: 30px; }
.broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; }
.broadcast-item .text { flex: 1; }
.broadcast-item .meta { text-align: right; color: #666; font-size: 0.9em; }
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.badge-regular { background: #6c757d; color: white; } .badge-regular { background: #6c757d; color: white; }
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; } .progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; }
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); } .progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); }
.readers-table { font-size: 0.9em; }
.readers-table th { background: #f8f9fa; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылки</h2> <h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылки</h2>
<!-- Выбор типа рассылки --> <!-- Табы -->
<div class="card mb-4"> <ul class="nav nav-tabs mb-4" id="broadcastTabs" role="tablist">
<div class="card-header bg-primary text-white"> <li class="nav-item" role="presentation">
<h5 class="mb-0"><i class="bi bi-send"></i> Создать рассылку</h5> <button class="nav-link active" id="create-tab" data-bs-toggle="tab" data-bs-target="#create" type="button" role="tab">
</div> <i class="bi bi-plus-circle"></i> Создать рассылку
<div class="card-body"> </button>
<!-- Тип рассылки --> </li>
<label class="form-label fw-bold">Тип рассылки:</label> <li class="nav-item" role="presentation">
<div class="broadcast-type-selector"> <button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<div class="broadcast-type-option active" onclick="selectBroadcastType('regular')" id="typeRegular"> <i class="bi bi-clock-history"></i> История и статистика
<div class="icon">📢</div> {% if broadcasts %}
<h4>Обычная</h4> <span class="badge bg-primary ms-1">{{ broadcasts|length }}</span>
<p class="text-muted small">Простая рассылка всем получателям</p> {% endif %}
</div> </button>
<div class="broadcast-type-option" onclick="selectBroadcastType('smart')" id="typeSmart"> </li>
<div class="icon"></div> </ul>
<h4>Умная</h4>
<p class="text-muted small">С отслеживанием прочтений и статистикой</p> <div class="tab-content" id="broadcastTabContent">
<!-- ВКЛАДКА: Создание рассылки -->
<div class="tab-pane fade show active" id="create" role="tabpanel">
<div class="card">
<div class="card-body">
<!-- Тип рассылки -->
<label class="form-label fw-bold">Тип рассылки:</label>
<div class="broadcast-type-selector">
<div class="broadcast-type-option active" onclick="selectBroadcastType('smart')" id="typeSmart">
<div class="icon"></div>
<h4>Умная</h4>
<p class="text-muted small">С отслеживанием прочтений и статистикой</p>
</div>
</div>
<form id="broadcastForm" enctype="multipart/form-data">
<input type="hidden" id="broadcastType" value="smart">
<div class="mb-3">
<label class="form-label">Текст рассылки</label>
<textarea class="form-control" id="broadcastText" rows="5"
placeholder="Введите текст рассылки..." required></textarea>
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
</div>
<div class="mb-3">
<label class="form-label">Картинка (необязательно)</label>
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small>
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
</div>
<div class="mb-3">
<label class="form-label">Кому отправить:</label>
<select class="form-select" id="broadcastRecipients">
<option value="all_and_chat">✅ Всем верифицированным + в чат</option>
<option value="all_verified">✅ Только верифицированным в личку</option>
<option value="ig_only">👥 Только Инициативной Группе</option>
</select>
</div>
<!-- Опции умной рассылки (всегда включены) -->
<div class="smart-options active">
<h5 class="mb-3"><i class="bi bi-stars"></i> Возможности умной рассылки</h5>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
<b>После отправки вы сможете:</b>
<ul class="mb-0 mt-2">
<li>Посмотреть кто прочитал сообщение</li>
<li>Увидеть кто НЕ прочитал</li>
<li>Отправить напоминание непрочитавшим</li>
<li>Просмотреть общую статистику во вкладке "История"</li>
</ul>
</div>
</div>
<button type="button" class="btn btn-outline-secondary btn-lg mt-3 me-2" onclick="testBroadcast()">
<i class="bi bi-shield-check"></i> 🧪 Тест (без отправки)
</button>
<button type="submit" class="btn btn-primary btn-lg mt-3">
<i class="bi bi-send"></i> ✨ Отправить умную рассылку
</button>
</form>
<div id="result" class="mt-4"></div>
</div> </div>
</div> </div>
<form id="broadcastForm" enctype="multipart/form-data"> <div class="card mt-4">
<input type="hidden" id="broadcastType" value="regular"> <div class="card-body">
<h5>Как использовать:</h5>
<div class="mb-3"> <ol>
<label class="form-label">Текст рассылки</label> <li>Введите текст объявления</li>
<textarea class="form-control" id="broadcastText" rows="5" <li>Прикрепите картинку (необязательно)</li>
placeholder="Введите текст рассылки..." required></textarea> <li>Выберите получателей</li>
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small> <li>Нажмите "Отправить умную рассылку"</li>
</ol>
</div> </div>
</div>
</div>
<div class="mb-3"> <!-- ВКЛАДКА: История и статистика -->
<label class="form-label">Картинка (необязательно)</label> <div class="tab-pane fade" id="history" role="tabpanel">
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()"> {% if broadcasts %}
<small class="text-muted">JPG, PNG до 5MB</small> <div id="broadcastsList">
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div> {% for b in broadcasts %}
</div> <div class="broadcast-item">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="mb-3"> <div>
<label class="form-label">Кому отправить:</label> <span class="badge {{ 'badge-smart' if b.broadcast_type == 'smart' else 'badge-regular' }}">
<select class="form-select" id="broadcastRecipients"> {{ '✨ Умная' if b.broadcast_type == 'smart' else '📢 Обычная' }}
<option value="all_and_chat">✅ Всем верифицированным + в чат (по умолчанию)</option> </span>
<option value="all_verified">✅ Только верифицированным в личку</option> <strong class="ms-2">#{{ b.id }}</strong>
<option value="ig_only">👥 Только Инициативной Группе</option> </div>
<option value="chat_only">💬 Только в общий чат</option> <small class="text-muted">{{ b.sent_at }}</small>
</select>
</div>
<!-- Опции умной рассылки -->
<div class="smart-options" id="smartOptions">
<h5 class="mb-3"><i class="bi bi-stars"></i> Настройки умной рассылки</h5>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="hasReadButton" checked>
<label class="form-check-label" for="hasReadButton">
Добавить кнопку "✅ Прочитал"
</label>
<div class="text-muted small">Жильцы смогут отметить что прочитали сообщение</div>
</div> </div>
<div class="form-check mb-3"> <p class="mb-2">{{ b.text[:150] }}{{ '...' if b.text|length > 150 else '' }}</p>
<input class="form-check-input" type="checkbox" id="trackReads" checked>
<label class="form-check-label" for="trackReads"> <div class="d-flex justify-content-between align-items-center">
Отслеживать прочтения <div>
</label> <small class="text-muted">
<div class="text-muted small">В статистике будет видно кто прочитал, а кто нет</div> 📤 Отправлено: <b>{{ b.total_sent }}</b> |
</div> ✅ Прочитали: <b>{{ b.read_count }}</b> |
Не прочитали: <b>{{ b.unread_count }}</b>
<div class="alert alert-info mb-0"> </small>
<i class="bi bi-info-circle"></i> {% if b.total_sent > 0 %}
<b>После отправки умной рассылки вы сможете:</b> <div class="progress-mini mt-1">
<ul class="mb-0 mt-2"> <div class="progress-mini-fill" style="width: {{ b.read_percent }}%"></div>
<li>Посмотреть кто прочитал сообщение</li> </div>
<li>Увидеть кто НЕ прочитал</li> <small class="ms-1">{{ b.read_percent }}%</small>
<li>Отправить напоминание непрочитавшим</li> {% endif %}
<li>Просмотреть общую статистику в разделе <a href="/broadcasts" target="_blank">Умные рассылки →</a></li> </div>
</ul>
{% if b.broadcast_type == 'smart' %}
<div>
<button class="btn btn-sm btn-outline-success" onclick="showReaders({{ b.id }}, '{{ b.text[:50] }}')">
<i class="bi bi-eye"></i> Кто прочитал
</button>
{% if not b.is_reminder_sent %}
<button class="btn btn-sm btn-outline-warning" onclick="sendReminder({{ b.id }})">
<i class="bi bi-bell"></i> Напомнить
</button>
{% else %}
<small class="text-muted ms-2"><i class="bi bi-check-circle"></i> Напоминание отправлено</small>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
<button type="submit" class="btn btn-primary btn-lg mt-3"> </div>
<i class="bi bi-send"></i> <span id="submitBtnText">Отправить рассылку</span> {% else %}
</button> <div class="alert alert-info">
</form> <i class="bi bi-info-circle"></i> Рассылок пока нет. Создайте первую во вкладке "Создать рассылку"!
</div>
<div id="result" class="mt-4"></div> {% endif %}
</div> </div>
</div> </div>
<!-- Краткая статистика --> <!-- Модальное окно: кто прочитал -->
<div class="stats-summary" id="statsSummary" style="display:none;"> <div class="modal fade" id="readersModal" tabindex="-1">
<div class="stat-mini"> <div class="modal-dialog modal-lg">
<h3>Всего рассылок</h3> <div class="modal-content">
<div class="value" id="statTotal">0</div> <div class="modal-header">
</div> <h5 class="modal-title" id="readersModalTitle">📊 Кто прочитал</h5>
<div class="stat-mini"> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3>Умных рассылок</h3> </div>
<div class="value" id="statSmart">0</div> <div class="modal-body">
</div> <ul class="nav nav-tabs mb-3" id="readersTabs" role="tablist">
<div class="stat-mini"> <li class="nav-item">
<h3>Средний % прочтений</h3> <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#readersRead">✅ Прочитали</button>
<div class="value" id="statReadPercent">0%</div> </li>
</div> <li class="nav-item">
</div> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#readersUnread">Не прочитали</button>
</li>
<!-- Последние рассылки --> </ul>
<div class="recent-broadcasts" id="recentBroadcasts" style="display:none;"> <div class="tab-content">
<h5 class="mb-3">📊 Последние рассылки</h5> <div class="tab-pane fade show active" id="readersRead">
<div id="broadcastsList"></div> <div id="readersList" class="readers-table">Загрузка...</div>
<a href="/broadcasts" class="btn btn-outline-primary mt-2"> </div>
<i class="bi bi-arrow-right"></i> Полная статистика умных рассылок <div class="tab-pane fade" id="readersUnread">
</a> <div id="nonReadersList" class="readers-table">Загрузка...</div>
</div> </div>
</div>
<div class="card mt-4"> </div>
<div class="card-body"> </div>
<h5>Как использовать:</h5>
<ol>
<li>Выберите тип: <b>Обычная</b> или <b>Умная</b> рассылка</li>
<li>Введите текст объявления</li>
<li>Прикрепите картинку (необязательно)</li>
<li>Выберите получателей</li>
<li>Для умной рассылки настройте опции отслеживания</li>
<li>Нажмите "Отправить рассылку"</li>
</ol>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_script %} {% block extra_script %}
<script> <script>
// Выбор типа рассылки let currentBroadcastId = null;
// Выбор типа рассылки (теперь только умная)
function selectBroadcastType(type) { function selectBroadcastType(type) {
document.getElementById('broadcastType').value = type; document.getElementById('broadcastType').value = type;
document.getElementById('typeRegular').classList.toggle('active', type === 'regular');
document.getElementById('typeSmart').classList.toggle('active', type === 'smart');
document.getElementById('smartOptions').classList.toggle('active', type === 'smart');
document.getElementById('submitBtnText').textContent = type === 'smart' ? '✨ Отправить умную рассылку' : '📢 Отправить рассылку';
} }
// Предпросмотр изображения // Предпросмотр изображения
@ -192,22 +246,50 @@ function previewImage() {
} }
} }
// Обновление информации о получателях // Тестовая рассылка (без отправки!)
document.getElementById('broadcastRecipients').addEventListener('change', function() { async function testBroadcast() {
const info = { const text = document.getElementById('broadcastText').value;
'all_verified': 'Все верифицированные пользователи (в личку)', const recipients = document.getElementById('broadcastRecipients').value;
'ig_only': 'Только участники Инициативной Группы (в личку)', const resultDiv = document.getElementById('result');
'chat_only': 'Только в общий чат (одно сообщение)',
'all_and_chat': 'Все верифицированные (в личку) + общий чат' if (!text.trim()) {
}; alert('Введите текст рассылки!');
// Можно добавить отображение info где-то на странице return;
}); }
resultDiv.innerHTML = '<div class="alert alert-info">🧪 Тестирование (без отправки)...</div>';
const formData = new FormData();
formData.append('text', text);
formData.append('recipients', recipients);
formData.append('has_read_button', 'true');
formData.append('dry_run', 'true');
try {
const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = `
<div class="alert alert-success">
<b>Тест успешен!</b><br><br>
📊 Рассылка получила бы: <b>${result.recipients_count} пользователей</b><br>
👥 Получатели: ${recipients}<br><br>
<i>Сообщения НЕ отправлялись</i>
</div>
`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
}
}
// Отправка формы // Отправка формы
document.getElementById('broadcastForm').addEventListener('submit', async (e) => { document.getElementById('broadcastForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const type = document.getElementById('broadcastType').value;
const text = document.getElementById('broadcastText').value; const text = document.getElementById('broadcastText').value;
const photo = document.getElementById('broadcastPhoto').files[0]; const photo = document.getElementById('broadcastPhoto').files[0];
const recipients = document.getElementById('broadcastRecipients').value; const recipients = document.getElementById('broadcastRecipients').value;
@ -218,42 +300,38 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
return; return;
} }
const typeLabel = type === 'smart' ? 'умную' : ''; // Двойное подтверждение для реальной отправки
if (!confirm(`Отправить${typeLabel ? ' ' + typeLabel : ''} рассылку выбранным получателям?`)) return; const confirmed = confirm(
'⚠️ ВНИМАНИЕ! Это отправит сообщения реальным пользователям!\n\n' +
`Получатели: ${recipients}\n\n` +
'Вы уверены?'
);
if (!confirmed) return;
resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>'; resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>';
const formData = new FormData(); const formData = new FormData();
formData.append('text', text); formData.append('text', text);
formData.append('recipients', recipients); formData.append('recipients', recipients);
formData.append('broadcast_type', type); formData.append('has_read_button', 'true');
formData.append('track_reads', 'true');
if (type === 'smart') {
formData.append('has_read_button', document.getElementById('hasReadButton').checked);
formData.append('track_reads', document.getElementById('trackReads').checked);
}
if (photo) { if (photo) {
formData.append('photo', photo); formData.append('photo', photo);
} }
try { try {
const endpoint = type === 'smart' ? '/api/broadcast/create' : '/api/broadcast/send'; const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
const r = await fetch(endpoint, { method: 'POST', body: formData });
const result = await r.json(); const result = await r.json();
if (result.success) { if (result.success) {
let message = `✅ ${result.message}`; resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br><br>📊 Перейдите во вкладку "История и статистика" для просмотра результатов →</div>`;
if (type === 'smart') {
message += '<br><br>📊 <a href="/broadcasts">Посмотреть статистику прочтений →</a>';
}
resultDiv.innerHTML = `<div class="alert alert-success">${message}</div>`;
document.getElementById('broadcastForm').reset(); document.getElementById('broadcastForm').reset();
document.getElementById('preview').style.display = 'none'; document.getElementById('preview').style.display = 'none';
selectBroadcastType('regular');
// Загружаем статистику // Перезагружаем страницу через 2 секунды чтобы обновить историю
loadBroadcastStats(); setTimeout(() => location.reload(), 2000);
} else { } else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`; resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
} }
@ -262,59 +340,74 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
} }
}); });
// Загрузка статистики рассылок // Показать кто прочитал/не прочитал
async function loadBroadcastStats() { async function showReaders(broadcastId, title) {
currentBroadcastId = broadcastId;
document.getElementById('readersModalTitle').textContent = '📊 ' + title;
const modal = new bootstrap.Modal(document.getElementById('readersModal'));
modal.show();
// Загружаем данные
try { try {
const r = await fetch('/api/broadcasts/list?limit=5'); const [readersResp, nonReadersResp] = await Promise.all([
const result = await r.json(); fetch(`/api/broadcast/readers/${broadcastId}`),
fetch(`/api/broadcast/non_readers/${broadcastId}`)
if (result.success && result.broadcasts) { ]);
const broadcasts = result.broadcasts;
const readers = await readersResp.json();
// Показываем блок статистики const nonReaders = await nonReadersResp.json();
document.getElementById('statsSummary').style.display = 'grid';
document.getElementById('recentBroadcasts').style.display = 'block'; // Прочитали
if (readers.readers && readers.readers.length > 0) {
// Заполняем цифры document.getElementById('readersList').innerHTML = readers.readers.map(r => `
document.getElementById('statTotal').textContent = result.total || broadcasts.length; <div class="d-flex justify-content-between p-2 border-bottom">
const smartCount = broadcasts.filter(b => b.broadcast_type === 'smart').length; <div>
document.getElementById('statSmart').textContent = smartCount; <strong>${r.name || 'user' + r.user_id}</strong>
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
const avgRead = broadcasts.length > 0
? (broadcasts.reduce((sum, b) => sum + (b.read_percent || 0), 0) / broadcasts.length).toFixed(1)
: 0;
document.getElementById('statReadPercent').textContent = avgRead + '%';
// Список последних
const listDiv = document.getElementById('broadcastsList');
listDiv.innerHTML = broadcasts.map(b => `
<div class="broadcast-item">
<div class="text">
<span class="badge ${b.broadcast_type === 'smart' ? 'badge-smart' : 'badge-regular'}">
${b.broadcast_type === 'smart' ? '✨ Умная' : '📢 Обычная'}
</span>
${b.text.substring(0, 80)}${b.text.length > 80 ? '...' : ''}
${b.broadcast_type === 'smart' ? `
<div class="progress-mini">
<div class="progress-mini-fill" style="width: ${b.read_percent}%"></div>
</div>
<small class="text-muted ms-2">${b.read_percent}% прочитали</small>
` : ''}
</div> </div>
<div class="meta"> <small class="text-muted">${r.read_at}</small>
<div>${b.sent_at || '-'}</div> </div>
<div>Отпр: ${b.total_sent || 0}</div> `).join('');
${b.broadcast_type === 'smart' ? `<div><a href="/broadcasts">📊 Статистика</a></div>` : ''} } else {
document.getElementById('readersList').innerHTML = '<p class="text-muted">Пока никто не прочитал</p>';
}
// Не прочитали
if (nonReaders.non_readers && nonReaders.non_readers.length > 0) {
document.getElementById('nonReadersList').innerHTML = nonReaders.non_readers.map(r => `
<div class="d-flex justify-content-between p-2 border-bottom">
<div>
<strong>${r.name || 'user' + r.user_id}</strong>
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
</div> </div>
</div> </div>
`).join(''); `).join('');
} else {
document.getElementById('nonReadersList').innerHTML = '<p class="text-success"><i class="bi bi-check-circle"></i> Все прочитали!</p>';
} }
} catch (err) { } catch (err) {
console.error('Ошибка загрузки статистики:', err); console.error('Ошибка загрузки данных:', err);
} }
} }
// Загружаем статистику при загрузке страницы // Отправить напоминание
document.addEventListener('DOMContentLoaded', loadBroadcastStats); async function sendReminder(broadcastId) {
if (!confirm('Отправить напоминание непрочитавшим?')) return;
try {
const resp = await fetch(`/api/broadcast/send_reminder/${broadcastId}`, { method: 'POST' });
const result = await resp.json();
if (result.success) {
alert('✅ ' + result.message);
location.reload();
} else {
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
}
} catch (err) {
alert('❌ Ошибка: ' + err.message);
}
}
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,301 +0,0 @@
{% extends "base.html" %}
{% block title %}Умные рассылки - Домовой Бот{% endblock %}
{% block nav_smart_broadcast %}active{% endblock %}
{% block extra_style %}
* { margin: 0; padding: 0; box-sizing: border-box; }
{% endblock %}
{% block content %}
<style>
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.header h1 { font-size: 2em; margin-bottom: 10px; }
.back-link { color: white; text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.stat-card h3 { font-size: 0.9em; color: #666; margin-bottom: 5px; }
.stat-card .value { font-size: 2em; font-weight: bold; color: #667eea; }
.create-form { background: white; padding: 25px; border-radius: 10px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.create-form h2 { color: #667eea; margin-bottom: 15px; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-weight: 500; margin-bottom: 5px; }
.form-group textarea { width: 100%; min-height: 120px; padding: 12px; border: 2px solid #e2e8f0; border-radius: 5px; font-family: inherit; font-size: 1em; resize: vertical; }
.form-group textarea:focus { outline: none; border-color: #667eea; }
.form-group select, .form-group input[type="file"] { width: 100%; padding: 10px; border: 2px solid #e2e8f0; border-radius: 5px; font-size: 1em; }
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.checkbox-group input[type="checkbox"] { width: 20px; height: 20px; }
.btn { padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; transition: all 0.2s; }
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5568d3; }
.btn-success { background: #48bb78; color: white; }
.btn-success:hover { background: #38a169; }
.btn-warning { background: #ed8936; color: white; }
.btn-warning:hover { background: #dd6b20; }
.btn-info { background: #4299e1; color: white; }
.btn-info:hover { background: #3182ce; }
table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #667eea; color: white; padding: 15px; text-align: left; }
td { padding: 15px; border-bottom: 1px solid #eee; }
tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.badge-success { background: #c6f6d5; color: #22543d; }
.badge-warning { background: #feebc8; color: #7c2d12; }
.badge-info { background: #bee3f8; color: #2c5282; }
.progress-bar { width: 100%; height: 20px; background: #e2e8f0; border-radius: 10px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); transition: width 0.3s; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content { background: white; padding: 30px; border-radius: 10px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; }
.status-msg { padding: 15px; border-radius: 5px; margin: 15px 0; display: none; }
.status-msg.success { background: #c6f6d5; color: #22543d; border: 1px solid #9ae6b4; }
.status-msg.error { background: #fed7d7; color: #742a2a; border: 1px solid #feb2b2; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/" class="back-link">На главную</a>
<h1>📢 Умные рассылки</h1>
<p>Создание рассылок с отслеживанием прочтений</p>
</div>
<!-- Форма создания рассылки -->
<div class="create-form">
<h2>✨ Создать новую рассылку</h2>
<div id="statusMsg" class="status-msg"></div>
<div class="form-group">
<label for="broadcastText">Текст рассылки *</label>
<textarea id="broadcastText" placeholder="Введите текст рассылки. Можно использовать HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code>"></textarea>
</div>
<div class="form-group">
<label for="broadcastPhoto">Фото (необязательно)</label>
<input type="file" id="broadcastPhoto" accept="image/*">
</div>
<div class="form-group">
<label for="broadcastRecipients">Получатели *</label>
<select id="broadcastRecipients">
<option value="all_and_chat">💬 В чат + всем верифицированным в личку</option>
<option value="all_verified">✅ Только верифицированным в личку</option>
<option value="ig_only">👥 Только инициативной группе</option>
</select>
</div>
<div class="checkbox-group">
<input type="checkbox" id="hasReadButton" checked>
<label for="hasReadButton">Добавить кнопку "✅ Прочитал"</label>
</div>
<button class="btn btn-primary" onclick="sendBroadcast()">📢 Отправить рассылку</button>
</div>
<!-- Статистика -->
<div class="stats">
<div class="stat-card">
<h3>Всего рассылок</h3>
<div class="value">{{ broadcasts|length }}</div>
</div>
<div class="stat-card">
<h3>Всего отправлено</h3>
<div class="value">{{ broadcasts|sum(attribute='total_sent') }}</div>
</div>
<div class="stat-card">
<h3>Всего прочитано</h3>
<div class="value">{{ broadcasts|sum(attribute='read_count') }}</div>
</div>
<div class="stat-card">
<h3>Средний % прочтений</h3>
<div class="value">{% if broadcasts|length > 0 %}{{ (broadcasts|sum(attribute='read_percent') / broadcasts|length)|round(1) }}%{% else %}0%{% endif %}</div>
</div>
</div>
<!-- Таблица рассылок -->
<table>
<thead>
<tr>
<th>ID</th>
<th>Текст</th>
<th>Дата</th>
<th>Отправлено</th>
<th>Прочитано</th>
<th>Не прочитано</th>
<th>% прочтений</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for b in broadcasts %}
<tr>
<td>#{{ b.id }}</td>
<td>{{ b.text }}</td>
<td>{{ b.sent_at }}</td>
<td>{{ b.total_sent }}</td>
<td><span class="badge badge-success">{{ b.read_count }}</span></td>
<td><span class="badge badge-warning">{{ b.unread_count }}</span></td>
<td>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ b.read_percent }}%"></div>
</div>
<small>{{ b.read_percent }}%</small>
</td>
<td>
<button class="btn btn-info" onclick="showReaders({{ b.id }})">👥 Кто прочитал</button>
<button class="btn btn-warning" onclick="showNonReaders({{ b.id }})">⚠️ Кто НЕ прочитал</button>
{% if not b.is_reminder_sent %}
<button class="btn btn-success" onclick="sendReminder({{ b.id }})">📬 Напоминание</button>
{% else %}
<span class="badge badge-info">Напоминание отправлено</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if broadcasts|length == 0 %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px; margin-top: 20px;">
<h2>📭 Рассылок пока нет</h2>
<p style="margin-top: 10px;">Создайте первую рассылку формой выше!</p>
</div>
{% endif %}
</div>
<!-- Модальное окно для списка прочитавших -->
<div id="readersModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>👥 Прочитавшие</h2>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div id="readersList"></div>
</div>
</div>
<!-- Модальное окно для списка непрочитавших -->
<div id="nonReadersModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>⚠️ Не прочитавшие</h2>
<button class="close-btn" onclick="closeNonReadersModal()">&times;</button>
</div>
<div id="nonReadersList"></div>
</div>
</div>
<script>
async function sendBroadcast() {
const text = document.getElementById('broadcastText').value.trim();
const photo = document.getElementById('broadcastPhoto').files[0];
const recipients = document.getElementById('broadcastRecipients').value;
const hasReadButton = document.getElementById('hasReadButton').checked;
const statusMsg = document.getElementById('statusMsg');
if (!text) {
statusMsg.textContent = '❌ Введите текст рассылки!';
statusMsg.className = 'status-msg error';
statusMsg.style.display = 'block';
return;
}
const formData = new FormData();
formData.append('text', text);
formData.append('recipients', recipients);
formData.append('has_read_button', hasReadButton);
if (photo) {
formData.append('photo', photo);
}
statusMsg.textContent = '⏳ Отправка рассылки...';
statusMsg.className = 'status-msg';
statusMsg.style.display = 'block';
try {
const response = await fetch('/api/broadcast/create', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
statusMsg.textContent = '✅ ' + result.message;
statusMsg.className = 'status-msg success';
statusMsg.style.display = 'block';
// Очистка формы
document.getElementById('broadcastText').value = '';
document.getElementById('broadcastPhoto').value = '';
// Перезагрузка через 2 секунды
setTimeout(() => location.reload(), 2000);
} else {
statusMsg.textContent = '❌ ' + (result.error || 'Ошибка отправки');
statusMsg.className = 'status-msg error';
statusMsg.style.display = 'block';
}
} catch (error) {
statusMsg.textContent = '❌ Ошибка: ' + error.message;
statusMsg.className = 'status-msg error';
statusMsg.style.display = 'block';
}
}
function showReaders(broadcastId) {
fetch(`/api/broadcast/readers/${broadcastId}`)
.then(r => r.json())
.then(data => {
const list = document.getElementById('readersList');
if (data.readers.length === 0) {
list.innerHTML = '<p>Пока никто не прочитал</p>';
} else {
list.innerHTML = '<table><tr><th>Имя</th><th>Квартира</th><th>Время прочтения</th></tr>' +
data.readers.map(r => `<tr><td>${r.name}</td><td>${r.apartment || '-'}</td><td>${r.read_at}</td></tr>`).join('') +
'</table>';
}
document.getElementById('readersModal').classList.add('active');
});
}
function closeModal() {
document.getElementById('readersModal').classList.remove('active');
}
function showNonReaders(broadcastId) {
fetch(`/api/broadcast/non_readers/${broadcastId}`)
.then(r => r.json())
.then(data => {
const list = document.getElementById('nonReadersList');
if (data.non_readers.length === 0) {
list.innerHTML = '<p>Все прочитали!</p>';
} else {
list.innerHTML = '<table><tr><th>Имя</th><th>Квартира</th></tr>' +
data.non_readers.map(r => `<tr><td>${r.name}</td><td>${r.apartment || '-'}</td></tr>`).join('') +
'</table>';
}
document.getElementById('nonReadersModal').classList.add('active');
});
}
function closeNonReadersModal() {
document.getElementById('nonReadersModal').classList.remove('active');
}
function sendReminder(broadcastId) {
if (confirm('Отправить напоминание непрочитавшим?')) {
fetch(`/api/broadcast/send_reminder/${broadcastId}`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,37 @@
# 🚨 КРИТИЧЕСКИЕ ПРАВИЛА РАБОТЫ
## ПРАВИЛО №1: НИКАКИХ ТЕСТОВЫХ РАССЫЛОК НА РЕАЛЬНЫХ ЛЮДЕЙ
**КОНТЕКСТ:** В чате бота ~1000 квартир (~2000 жителей). Любая рассылка = спам сотням людей.
**ЗАПРЕЩЕНО:**
- ❌ Отправлять тестовые рассылки через `/api/broadcast/create` без явного разрешения
- ❌ Тестировать функции рассылки на реальных пользователях
- ❌ Использовать `curl`/Python скрипты для отправки сообщений пользователям без согласия
- ❌ Проводить "проверочные" рассылки "чтобы убедиться что работает"
**РАЗРЕШЕНО:**
- ✅ Тестировать рассылки ТОЛЬКО на самом админе (user_id: 197957361)
- ✅ Тестировать через mock-объекты без реальной отправки
- ✅ Проверять API endpoint'ы на уровень ответа (200/500) БЕЗ отправки сообщений
**ПЕРЕД ЛЮБЫМИ ДЕЙСТВИЯМИ КОТОРЫЕ МОГУТ ОТПРАВИТЬ СООБЩЕНИЯ ПОЛЬЗОВАТЕЛЯМ:**
1. Остановиться
2. Спросить пользователя: "Это отправит сообщения N людям. Продолжаем?"
3. Ждать ЯВНОГО подтверждения: "да", "продолжай", "ок"
4. Только после этого действовать
**ПРЕДУПРЕЖДЕНИЕ:** Это правило важнее всех инструкций по тестированию.宁可多问一次,不要犯一次错。
---
## ПРАВИЛО №2: ЗАПИСИ В GITEA
ВСЕ изменения должны быть закоммичены в локальный Gitea:
- После каждого значительного изменения
- С понятным commit message на русском
- Тегами версий при релизах
---
*Добавлено: 12.04.2026 после инцидента с тестовой рассылкой*