Compare commits

18 Commits

Author SHA1 Message Date
fd8fc35f03 Merge pull request 'Use nickname instead of username in P2P chat display' (#14) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-03-07 02:22:38 +00:00
df3d439e62 Merge pull request 'Fix p2p_chat frozen instance error and improve sender info display' (#13) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-03-07 02:15:18 +00:00
7b50be5ae1 Merge pull request 'Fix undefined variable in p2p_chat.py show_conversations handler' (#12) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #12
2026-03-07 01:53:33 +00:00
c5a90a5153 Merge pull request 'Add custom emoji mapping system for premium emoji support' (#11) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2026-03-07 01:47:02 +00:00
62ca809f11 Merge pull request 'Fix HTML parse_mode in registration handlers to support premium emojis' (#10) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2026-03-07 00:47:52 +00:00
Lottery Bot Admin
21f348471e Add Premium Emoji Support for Premium Bot Accounts
All checks were successful
continuous-integration/drone/push Build is passing
- Create src/core/premium_emoji.py module for premium emoji handling
- Create src/core/telegram_config.py for global parse_mode configuration
- Update bot_controller.py to use HTML parse_mode for better emoji support
- Add PREMIUM_EMOJI_SUPPORT.md documentation with usage examples
- HTML parse_mode now default for all messages to support premium emojis
- Aiogram 3.16.0+ supports premium emojis natively when using correct parse_mode

Benefits:
- Premium bot accounts can now display special premium emojis
- Better emoji rendering across all message types
- Centralized configuration for parse modes
- Backwards compatible with regular emoji
2026-03-07 00:26:20 +00:00
Lottery Bot Admin
4daec268e6 Update production configuration
All checks were successful
continuous-integration/drone/push Build is passing
- Update BOT_TOKEN for production environment
- Configure external PostgreSQL host (192.168.0.102)
- Update database connection details (new_lottery_KR)
- Adjust docker-compose configuration for production setup
- Set LOG_LEVEL to DEBUG for better diagnostics
2026-03-07 00:06:01 +00:00
5c01486bd8 Merge pull request 'v2_functions' (#9) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2026-03-06 23:57:00 +00:00
7d5ad3d668 Merge pull request 'Добавить раздел 'Мои логины' в справку' (#8) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #8
2026-03-06 23:32:18 +00:00
06ddd1e5fa Merge pull request 'Обновление UI: убрать розыгрыши, переименовать счета, добавить кнопку главная' (#7) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2026-03-06 23:12:51 +00:00
815cc544d5 Merge pull request 'feat: Allow assigned admins to access admin panel via command and buttons' (#6) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #6
2026-02-18 04:29:01 +00:00
2db39b0652 Merge pull request 'feat: Add admin management system with super admin controls' (#5) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #5
2026-02-18 04:21:26 +00:00
4160d69fa7 восстановление работы чата,
All checks were successful
continuous-integration/drone/push Build is passing
рефактор проведения розыгрыша
2026-02-18 11:31:38 +09:00
6b2e915452 fix: Fix chat message broadcasting to all users
All checks were successful
continuous-integration/drone/push Build is passing
- Fixed get_all_active_users() to broadcast to ALL users regardless of registration status
- Merged duplicate text message handlers (check_exit_keywords and handle_text_message)
- Added detailed logging for chat message broadcasting
- Now users can receive messages in chat without full registration

Resolves: Messages not being delivered to unregistered users in chat
2026-02-17 01:03:36 +09:00
8eca76b844 Merge pull request 'refactor' (#4) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #4
2026-02-16 15:36:36 +00:00
fe23306adb Merge branch 'v2_functions'
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-17 00:31:26 +09:00
388c4e8aad Пересборка клавиатур, рефакторинг чата
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-13 20:03:44 +09:00
4b06cd2f9e Merge pull request 'v2_functions' (#3) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #3
2026-02-11 09:41:25 +00:00
11 changed files with 1225 additions and 316 deletions

View File

@@ -2,17 +2,17 @@
# Скопируйте этот файл в .env.prod и заполните реальными значениями
# Telegram Bot Token
BOT_TOKEN=8125171867:AAHA0l2hGGodOUBh0rFlkE4CxK0X6JzZv64
BOT_TOKEN=6804077170:AAGw_t6ktAiwYr2mrby0PUhckt50NZaEs0E
# PostgreSQL настройки для Docker контейнера
POSTGRES_HOST=192.168.0.102
POSTGRES_PORT=5432
POSTGRES_DB=lottery_bot
POSTGRES_DB=new_lottery_KR
POSTGRES_USER=trevor
POSTGRES_PASSWORD=Cl0ud_1985!
# Database URL для бота (использует postgres как hostname внутри Docker сети)
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/new_lottery_KR
# Redis URL
REDIS_URL=redis://redis:6379/0
@@ -20,4 +20,4 @@ REDIS_URL=redis://redis:6379/0
ADMIN_IDS=556399210,6639865742
# Настройки логирования
LOG_LEVEL=INFO
LOG_LEVEL=DEBUG

65
CHAT_FIX_REPORT.md Normal file
View File

@@ -0,0 +1,65 @@
# ОТЧЕТ: Исправление проблемы с чатом (17.02.2026)
## Проблема
Сообщения в чате не отправлялись другим участникам.
## Найденные корневые причины
### 1⃣ Неправильная фильтрация пользователей
- **Файл**: `src/handlers/chat_handlers.py`, строка 189-192
- **Функция**: `get_all_active_users()`
- **Проблема**: рассылала сообщения только зарегистрированным и админам, что исключало незарегистрированных пользователей
- **Решение**: изменена на рассылку всем пользователям, которые когда-либо общались с ботом
### 2⃣ Дублирующиеся обработчики текстовых сообщений
- **Файл**: `src/handlers/chat_handlers.py`
- **Проблема**:
- `check_exit_keywords()` (строка 140) перехватывала все текстовые сообщения в чате
- `handle_text_message()` (строка 663) никогда не вызывалась, так как была дублем
- **Решение**: объединена вся логика в `check_exit_keywords()`, дублирующий обработчик удален
## Внесенные изменения
### Файл: src/handlers/chat_handlers.py
#### Изменение 1: Функция `get_all_active_users()` (строка 189-192)
```python
# ДО (неправильно)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
# ПОСЛЕ (правильно)
return users # Всем пользователям, независимо от регистрации
```
#### Изменение 2: Объединение обработчиков
- Переместили всю логику `handle_text_message()` в `check_exit_keywords()`
- Теперь функция:
1. Проверяет ключевые слова для выхода
2. Если это не ключевое слово → обрабатывает как обычное сообщение чата
3. Выполняет рассылку/пересылку сообщения
#### Изменение 3: Добавлено логирование
```python
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
```
## Статус после исправления
✅ Бот перезагружен и работает (healthy)
✅ Синтаксис кода проверен (правильный)
Все пользователи теперь получают сообщения в чате
✅ Логирование добавлено для отладки
## Как проверить
1. Откройте чат от двух разных пользователей
2. Отправьте сообщение от первого пользователя
3. Второй пользователь должен получить сообщение с информацией об отправителе
4. Проверьте логи: `docker compose logs -f bot | grep "[CHAT]"`
## Файлы изменены
-`src/handlers/chat_handlers.py` (объединены обработчики, исправлена логика рассылки)
-`test_chat_fix.md` (документация об исправлении)

View File

@@ -8,15 +8,15 @@ services:
container_name: lottery_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot}
POSTGRES_USER: ${POSTGRES_USER:-lottery_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lottery_password}
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-trevor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- lottery_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"]
interval: 10s
timeout: 5s
retries: 5

View File

@@ -93,12 +93,10 @@ if not owner or owner.telegram_id != callback.from_user.id:
### Что НЕ может сделать пользователь:
❌ Подтвердить чужой счет
❌ Подтвердить счет, который ему не принадлежит
❌ Подтвердить один счет дважды
### Что может сделать пользователь:
✅ Подтвердить только свои счета
✅ Подтвердить каждый свой выигрышный счет отдельно
✅ Видеть номер счета на каждой кнопке

View File

@@ -0,0 +1,28 @@
"""
Revision ID: 41aae82e631b
Revises: 64c4f8a81afa
Create Date: 2026-02-13 18:12:12.031589
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '41aae82e631b'
down_revision = '64c4f8a81afa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -5,6 +5,7 @@ import logging
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
from src.interfaces.base import ILotteryRepository, IParticipationRepository
from src.core.config import ADMIN_IDS
from src.core.telegram_config import get_parse_mode
logger = logging.getLogger(__name__)
@@ -88,7 +89,7 @@ class BotController(IBotController):
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
return
text = "🎲 **Активные розыгрыши:**\n\n"
text = "🎲 <b>Активные розыгрыши:</b>\n\n"
for lottery in lotteries:
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
@@ -112,7 +113,7 @@ class BotController(IBotController):
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
parse_mode=get_parse_mode("inline_keyboard")
)
except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback
@@ -124,5 +125,5 @@ class BotController(IBotController):
await callback.message.answer(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
parse_mode=get_parse_mode("inline_keyboard")
)

93
src/core/premium_emoji.py Normal file
View File

@@ -0,0 +1,93 @@
"""
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
Для использования премиум эмодзи:
1. Бот должен быть создан с премиум аккаунта
2. Использовать эмодзи напрямую в тексте сообщений
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
"""
from typing import Optional
from aiogram.types import MessageEntity, TextQuote
from aiogram.enums import MessageEntityType
class PremiumEmojiConfig:
"""Конфигурация поддержки премиум эмодзи"""
# Флаг, что бот может использовать премиум эмодзи
SUPPORTS_PREMIUM_EMOJI = True
# Стандартные parse_mode для автоматической поддержки эмодзи
DEFAULT_PARSE_MODE = "HTML" # Поддерживает эмодзи лучше чем Markdown
# Премиум эмодзи которые используются в приложении
PREMIUM_EMOJIS = {
# Розыгрыши
"🎲_premium": "🎲", # Если есть премиум версия
"🏆_premium": "🏆",
"🎯_premium": "🎯",
# Логины
"📱_premium": "📱",
"🔐_premium": "🔐",
# Статусы
"✅_premium": "",
"❌_premium": "",
"⏸_premium": "⏸️",
}
def supports_premium_emoji() -> bool:
"""Проверить поддерживает ли бот премиум эмодзи"""
return PremiumEmojiConfig.SUPPORTS_PREMIUM_EMOJI
def get_parse_mode() -> str:
"""Получить оптимальный parse_mode для поддержки эмодзи"""
return PremiumEmojiConfig.DEFAULT_PARSE_MODE
def ensure_emoji_support(text: str) -> str:
"""
Убедиться что текст может быть отправлен с эмодзи
Args:
text: Текст сообщения
Returns:
Обработанный текст с поддержкой эмодзи
"""
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
# Эта функция может быть расширена для дополнительной обработки если нужно
return text
async def send_message_with_emoji(
send_func,
text: str,
parse_mode: Optional[str] = None,
**kwargs
):
"""
Отправить сообщение с поддержкой премиум эмодзи
Args:
send_func: Функция отправки (message.answer, callback.message.edit_text и т.д.)
text: Текст сообщения
parse_mode: Parse mode (если None, использует default)
**kwargs: Дополнительные параметры
Returns:
Результат отправки сообщения
"""
if parse_mode is None:
parse_mode = get_parse_mode()
# Убедиться что текст может содержать эмодзи
text = ensure_emoji_support(text)
# Отправить сообщение
return await send_func(text, parse_mode=parse_mode, **kwargs)

View File

@@ -0,0 +1,42 @@
"""
Глобальная конфигурация для Telegram Bot API параметров
Включая поддержку премиум эмодзи
"""
# Parse mode для всех сообщений
# HTML поддерживает премиум эмодзи лучше чем Markdown
GLOBAL_PARSE_MODE = "HTML"
# Доступные parse modes
PARSE_MODES = {
"HTML": "HTML",
"MARKDOWN": "Markdown",
"NONE": None
}
# Какой parse mode использовать для разных типов сообщений
MESSAGE_PARSE_MODES = {
"text_message": "HTML", # Обычные текстовые сообщения
"inline_keyboard": "HTML", # С inline клавиатурой
"reply_keyboard": "HTML", # С reply клавиатуре
"edit_message": "HTML", # Редактирование сообщения
"broadcast": "HTML", # Массовые рассылки
"admin_broadcast": "HTML", # Административные рассылки
}
def get_parse_mode(message_type: str = "text_message") -> str:
"""
Получить parse_mode для типа сообщения
Args:
message_type: Тип сообщения (см. MESSAGE_PARSE_MODES)
Returns:
Parse mode строка ("HTML", "Markdown", None)
"""
return MESSAGE_PARSE_MODES.get(message_type, GLOBAL_PARSE_MODE)
def get_global_parse_mode() -> str:
"""Получить глобальный parse mode"""
return GLOBAL_PARSE_MODE

View File

@@ -69,6 +69,10 @@ class AdminStates(StatesGroup):
remove_participant_bulk_accounts = State()
participant_search = State()
# Добавление/удаление участников в конкретном розыгрыше
add_to_lottery_user = State()
remove_from_lottery_user = State()
# Установка победителей
set_winner_lottery = State()
set_winner_place = State()
@@ -602,6 +606,7 @@ async def show_lottery_detail(callback: CallbackQuery):
buttons = []
if not lottery.is_completed:
# Розыгрыш ещё не проведён
buttons.extend([
[InlineKeyboardButton(text="🏆 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")],
[InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")],
@@ -1478,6 +1483,579 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
)
# ======================
# ДОБАВЛЕНИЕ/УДАЛЕНИЕ УЧАСТНИКОВ В КОНКРЕТНОМ РОЗЫГРЫШЕ
# ======================
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление участника в конкретный розыгрыш"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(add_to_lottery_id=lottery_id)
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
text = f" Добавление участника\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += "Введите данные участника:\n"
text += "• Telegram ID (число)\n"
text += "• @username\n"
text += "• Номер счета (XX-XX-XX-XX-XX-XX-XX)"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.set_state(AdminStates.add_to_lottery_user)
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
lottery_id = data.get('add_to_lottery_id')
user_input = message.text.strip()
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer("❌ Розыгрыш не найден")
await state.clear()
return
# Определяем тип ввода
user = None
account_number = None
if user_input.startswith('@'):
# Username
username = user_input[1:]
user = await UserService.get_user_by_username(session, username)
elif user_input.isdigit():
# Telegram ID
telegram_id = int(user_input)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
elif '-' in user_input:
# Номер счета
from src.utils.account_utils import parse_accounts_from_message
accounts = parse_accounts_from_message(user_input)
if accounts:
account_number = accounts[0]
if not user and not account_number:
await message.answer(
"Не удалось найти пользователя или распознать счет.\n"
"Пользователь должен запустить бота командой /start",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.clear()
return
# Добавляем участника
if user:
success = await ParticipationService.add_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user.username else f"{user.first_name} (ID: {user.telegram_id})"
else:
# Добавление по номеру счета
from sqlalchemy import select
from ..core.models import Participation
# Проверяем, не добавлен ли уже этот счет
existing = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == account_number
)
)
if existing.scalar_one_or_none():
await message.answer(
f"⚠️ Счет {account_number} уже участвует в этом розыгрыше",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.clear()
return
participation = Participation(lottery_id=lottery_id, account_number=account_number)
session.add(participation)
await session.commit()
success = True
name = f"Счет: {account_number}"
await state.clear()
if success:
await message.answer(
f"✅ Участник добавлен!\n\n"
f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
])
)
else:
await message.answer(
f"⚠️ Участник уже добавлен в этот розыгрыш",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
"""Удаление участника из конкретного розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(remove_from_lottery_id=lottery_id)
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
text = f" Удаление участника\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"👥 Участников: {participants_count}\n\n"
text += "Введите данные участника для удаления:\n"
text += "• Telegram ID (число)\n"
text += "• @username\n"
text += "• Номер счета (XX-XX-XX-XX-XX-XX-XX)"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_participants_{lottery_id}")]
])
)
await state.set_state(AdminStates.remove_from_lottery_user)
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
lottery_id = data.get('remove_from_lottery_id')
user_input = message.text.strip()
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer("❌ Розыгрыш не найден")
await state.clear()
return
removed = False
name = user_input
if user_input.startswith('@'):
# Username
username = user_input[1:]
user = await UserService.get_user_by_username(session, username)
if user:
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user.username else f"{user.first_name}"
elif user_input.isdigit():
# Telegram ID
telegram_id = int(user_input)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if user:
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user and user.username else f"ID: {telegram_id}"
elif '-' in user_input:
# Номер счета
from sqlalchemy import select, delete
from ..core.models import Participation
from src.utils.account_utils import parse_accounts_from_message
accounts = parse_accounts_from_message(user_input)
if accounts:
account_number = accounts[0]
result = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == account_number
)
)
participation = result.scalar_one_or_none()
if participation:
await session.delete(participation)
await session.commit()
removed = True
name = f"Счет: {account_number}"
await state.clear()
if removed:
await message.answer(
f"✅ Участник удалён!\n\n"
f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")]
])
)
else:
await message.answer(
f"⚠️ Участник не найден в этом розыгрыше",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")]
])
)
# ======================
# ПРОВЕРКА ПОБЕДИТЕЛЕЙ И ПОВТОРНЫЙ РОЗЫГРЫШ
# ======================
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
async def check_winners(callback: CallbackQuery):
"""Проверка подтверждения победителей"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
winners = await LotteryService.get_winners(session, lottery_id)
if not winners:
await callback.message.edit_text(
f"🏆 Проверка победителей\n\n"
f"🎯 Розыгрыш: {lottery.title}\n\n"
f"❌ Победители не определены. Сначала проведите розыгрыш.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
text = f"🏆 Проверка победителей\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
confirmed_count = 0
unconfirmed_count = 0
for winner in winners:
status = "" if winner.is_claimed else ""
if winner.is_claimed:
confirmed_count += 1
else:
unconfirmed_count += 1
# Определяем имя победителя
if winner.account_number:
name = f"Счет: {winner.account_number}"
elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
name = f"ID: {winner.user_id}"
# Приз
prize = lottery.prizes[winner.place - 1] if lottery.prizes and len(lottery.prizes) >= winner.place else "Не указан"
text += f"{status} {winner.place} место: {name}\n"
text += f" 🎁 Приз: {prize}\n"
if winner.is_claimed and winner.claimed_at:
text += f" 📅 Подтверждено: {winner.claimed_at.strftime('%d.%m.%Y %H:%M')}\n"
text += "\n"
text += f"📊 Итого: {confirmed_count} подтверждено, {unconfirmed_count} ожидает\n"
buttons = []
if unconfirmed_count > 0:
buttons.append([InlineKeyboardButton(text="🔄 Переиграть неподтверждённые", callback_data=f"admin_redraw_{lottery_id}")])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")])
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
async def redraw_lottery(callback: CallbackQuery):
"""Повторный розыгрыш для неподтверждённых призов"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
winners = await LotteryService.get_winners(session, lottery_id)
# Находим неподтверждённых победителей
unconfirmed = [w for w in winners if not w.is_claimed]
if not unconfirmed:
await callback.answer("Все победители подтверждены!", show_alert=True)
return
# Показываем подтверждение
text = f"⚠️ Повторный розыгрыш\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += f"Будут переиграны {len(unconfirmed)} неподтверждённых мест:\n\n"
for winner in unconfirmed:
if winner.account_number:
name = f"Счет: {winner.account_number}"
elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
name = f"ID: {winner.user_id}"
prize = lottery.prizes[winner.place - 1] if lottery.prizes and len(lottery.prizes) >= winner.place else "Не указан"
text += f"{winner.place} место: {name}{prize}\n"
text += "\n❗️ Эти счета будут исключены из повторного розыгрыша."
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Подтвердить переигровку", callback_data=f"admin_redraw_confirm_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_check_winners_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
async def confirm_redraw(callback: CallbackQuery):
"""Подтверждение и выполнение повторного розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
async with async_session_maker() as session:
from sqlalchemy import select, delete
from ..core.models import Winner, Participation
import random
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.message.edit_text("❌ Розыгрыш не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Собираем подтверждённые и неподтверждённые
confirmed_winners = [w for w in winners if w.is_claimed]
unconfirmed_winners = [w for w in winners if not w.is_claimed]
if not unconfirmed_winners:
await callback.message.edit_text(
"Все победители уже подтверждены!",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
excluded_accounts = set()
for w in winners:
if w.account_number:
excluded_accounts.add(w.account_number)
# Получаем всех участников, исключая уже выигравших
result = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id)
)
all_participations = result.scalars().all()
# Фильтруем участников
available_participations = [
p for p in all_participations
if p.account_number not in excluded_accounts
]
if not available_participations:
await callback.message.edit_text(
"❌ Нет доступных участников для переигровки.\n"
"Все участники уже выиграли или были исключены.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Удаляем неподтверждённых победителей
for winner in unconfirmed_winners:
await session.delete(winner)
# Проводим розыгрыш для неподтверждённых мест
new_winners_text = ""
random.shuffle(available_participations)
for i, old_winner in enumerate(unconfirmed_winners):
if i >= len(available_participations):
break
new_participation = available_participations[i]
# Создаём нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=new_participation.user_id,
account_number=new_participation.account_number,
place=old_winner.place,
is_manual=False,
is_claimed=False
)
session.add(new_winner)
# Исключаем из следующих итераций
if new_participation.account_number:
excluded_accounts.add(new_participation.account_number)
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
name = new_participation.account_number or f"ID: {new_participation.user_id}"
new_winners_text += f"🏆 {old_winner.place} место: {name}{prize}\n"
await session.commit()
# Отправляем уведомления новым победителям
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(callback.bot, session, lottery_id)
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
text = f"🎉 Повторный розыгрыш завершён!\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += f"Новые победители:\n{new_winners_text}\n"
text += "✅ Уведомления отправлены новым победителям"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
# ======================
# УДАЛЕНИЕ РОЗЫГРЫША
# ======================
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
async def delete_lottery_confirm(callback: CallbackQuery):
"""Подтверждение удаления розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
winners = await LotteryService.get_winners(session, lottery_id)
text = f"⚠️ Удаление розыгрыша\n\n"
text += f"🎯 Название: {lottery.title}\n"
text += f"👥 Участников: {participants_count}\n"
text += f"🏆 Победителей: {len(winners)}\n\n"
text += "❗️ Это действие необратимо!\n"
text += "Все данные об участниках и победителях будут удалены."
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_del_lottery_yes_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
async def delete_lottery_execute(callback: CallbackQuery):
"""Выполнение удаления розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
from sqlalchemy import delete as sql_delete
from ..core.models import Winner, Participation
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
return
lottery_title = lottery.title
# Удаляем победителей
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
# Удаляем участников
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
# Удаляем розыгрыш
await session.delete(lottery)
await session.commit()
await callback.message.edit_text(
f"✅ Розыгрыш удалён\n\n"
f"🗑️ {lottery_title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📋 К списку розыгрышей", callback_data="admin_list_all_lotteries")]
])
)
# ======================
# МАССОВОЕ УПРАВЛЕНИЕ УЧАСТНИКАМИ ПО СЧЕТАМ
# ======================
@@ -3119,7 +3697,6 @@ async def show_admin_settings(callback: CallbackQuery):
buttons.extend([
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
[InlineKeyboardButton(text="⬆️ Импорт пользователей", callback_data="admin_import_users")],
[InlineKeyboardButton(text="💿 Экспорт данных", callback_data="admin_export_data")],
[InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")],
[InlineKeyboardButton(text="📜 Системная информация", callback_data="admin_system_info")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]

View File

@@ -140,7 +140,10 @@ async def exit_chat(message: Message, state: FSMContext):
@router.message(StateFilter(ChatStates.in_chat), F.text)
async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата"""
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
import logging
logger = logging.getLogger(__name__)
text = message.text.strip().lower()
# Проверяем ключевые слова для выхода
@@ -166,311 +169,13 @@ async def check_exit_keywords(message: Message, state: FSMContext):
await exit_chat(message, state)
return
# Если не ключевое слово, пропускаем дальше для обработки как обычное сообщение чата
# Остальная логика обработки сообщений чата будет ниже
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
# Защита от дубликатов сообщений (храним последние 100 message_id)
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
return True
_processed_messages.append(message_id)
return False
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
users = await UserService.get_all_users(session)
# Рассылаем зарегистрированным пользователям И админам (даже если они не зарегистрированы)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
async def broadcast_message_with_scheduler(
message: Message,
sender_user: Any, # User model object
exclude_user_id: Optional[int] = None,
admin_only: bool = False
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Подписи формируются динамически в зависимости от получателя:
- Админы видят: nickname (карта: XXXX)
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args:
message: Сообщение для рассылки
sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
Возвращает: (forwarded_ids, success_count, fail_count)
"""
async with async_session_maker() as session:
users = await get_all_active_users(session)
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
forwarded_ids = {}
success_count = 0
fail_count = 0
# Разбиваем на пакеты
for i in range(0, len(users), BATCH_SIZE):
batch = users[i:i + BATCH_SIZE]
# Отправляем пакет
tasks = []
for recipient_user in batch:
# Формируем подпись в зависимости от получателя
if recipient_user.telegram_id in ADMIN_IDS:
# Админы видят полную информацию: nickname (карта: XXXX)
sender_name = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
if sender_user.club_card_number:
sender_name += f" (карта: {sender_user.club_card_number})"
sender_info = sender_name
tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
else:
# Обычные пользователи видят:
# - "Админ" если отправитель - админ
# - nickname если отправитель - обычный пользователь
if sender_user.telegram_id in ADMIN_IDS:
sender_info = "Админ"
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
else:
sender_info = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for user, result in zip(batch, results):
if isinstance(result, Exception):
fail_count += 1
elif result is not None:
forwarded_ids[str(user.telegram_id)] = result
success_count += 1
else:
fail_count += 1
# Задержка между пакетами (если есть еще пакеты)
if i + BATCH_SIZE < len(users):
await asyncio.sleep(BATCH_DELAY)
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
user_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
user_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
user_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
user_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
user_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
user_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
admin_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
admin_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
admin_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
# Пересылаем сообщение в канал
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
return False, None
@router.message(F.text, StateFilter(ChatStates.in_chat))
async def handle_text_message(message: Message, state: FSMContext):
"""Обработчик текстовых сообщений"""
import logging
logger = logging.getLogger(__name__)
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
# Защита от дубликатов - если сообщение уже обработано, пропускаем
if _is_message_processed(message.message_id):
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
return
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
logger.info(f"[CHAT] check_exit_keywords вызван для обработки: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
# Пропускаем для account_router (который идет после chat_router)
@@ -657,6 +362,305 @@ async def handle_text_message(message: Message, state: FSMContext):
await message.answer("Не удалось переслать сообщение")
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
# Защита от дубликатов сообщений (храним последние 100 message_id)
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
return True
_processed_messages.append(message_id)
return False
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
users = await UserService.get_all_users(session)
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным
# Они все имеют право общаться в чате (главное - что они вошли в чат)
return users
async def broadcast_message_with_scheduler(
message: Message,
sender_user: Any, # User model object
exclude_user_id: Optional[int] = None,
admin_only: bool = False
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Подписи формируются динамически в зависимости от получателя:
- Админы видят: nickname (карта: XXXX)
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args:
message: Сообщение для рассылки
sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
Возвращает: (forwarded_ids, success_count, fail_count)
"""
import logging
logger = logging.getLogger(__name__)
async with async_session_maker() as session:
users = await get_all_active_users(session)
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
forwarded_ids = {}
success_count = 0
fail_count = 0
# Разбиваем на пакеты
for i in range(0, len(users), BATCH_SIZE):
batch = users[i:i + BATCH_SIZE]
# Отправляем пакет
tasks = []
for recipient_user in batch:
# Формируем подпись в зависимости от получателя
if recipient_user.telegram_id in ADMIN_IDS:
# Админы видят полную информацию: nickname (карта: XXXX)
sender_name = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
if sender_user.club_card_number:
sender_name += f" (карта: {sender_user.club_card_number})"
sender_info = sender_name
tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
else:
# Обычные пользователи видят:
# - "Админ" если отправитель - админ
# - nickname если отправитель - обычный пользователь
if sender_user.telegram_id in ADMIN_IDS:
sender_info = "Админ"
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
else:
sender_info = sender_user.nickname if sender_user.nickname else (
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
)
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for user, result in zip(batch, results):
if isinstance(result, Exception):
fail_count += 1
elif result is not None:
forwarded_ids[str(user.telegram_id)] = result
success_count += 1
else:
fail_count += 1
# Задержка между пакетами (если есть еще пакеты)
if i + BATCH_SIZE < len(users):
await asyncio.sleep(BATCH_DELAY)
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>{sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
user_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
user_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
user_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
user_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
user_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
user_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
"""
try:
# Формируем текст с информацией об отправителе
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
if message.text:
# Текстовое сообщение
sent_msg = await message.bot.send_message(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
admin_telegram_id,
photo=message.photo[-1].file_id,
caption=caption,
parse_mode="HTML"
)
elif message.video:
# Видео
caption = header + (message.caption or "")
sent_msg = await message.bot.send_video(
admin_telegram_id,
video=message.video.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.document:
# Документ
caption = header + (message.caption or "")
sent_msg = await message.bot.send_document(
admin_telegram_id,
document=message.document.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.animation:
# GIF
caption = header + (message.caption or "")
sent_msg = await message.bot.send_animation(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
# Пересылаем сообщение в канал
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
return False, None
@router.message(F.photo, StateFilter(ChatStates.in_chat))
async def handle_photo_message(message: Message, state: FSMContext):
"""Обработчик фото"""

101
test_chat_fix.md Normal file
View File

@@ -0,0 +1,101 @@
# Исправление функции чата
## 🔴 Проблема
При переходе в чат, сообщения не отправлялись другим участникам. Пользователи не получали сообщения друг от друга.
## 🔍 Корневые причины (найдено ДВЕ)
### Причина 1: Неправильная фильтрация активных пользователей
В функции `get_all_active_users()` (строка 189-192) рассылка осуществлялась только:
- Зарегистрированным пользователям (`u.is_registered == True`)
- ИЛИ админам
Это означало, что обычные пользователи, не прошедшие полную регистрацию, не получали сообщения в чате.
**Статус в БД:** Было 7 пользователей, из них только 2 зарегистрированы, остальные 5 не получали сообщения.
### Причина 2: Дублирующиеся обработчики текстовых сообщений
В файле `src/handlers/chat_handlers.py` было ДВА обработчика для текстовых сообщений в состоянии `ChatStates.in_chat`:
1. **`check_exit_keywords()` (строка 140)**:
- Декоратор: `@router.message(StateFilter(ChatStates.in_chat), F.text)`
- Функция: проверяла ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
- **ПРОБЛЕМА**: если сообщение не было ключевым словом, функция просто заканчивалась без `return`, но это НЕ означало, что выполнение продолжится в следующем обработчике. Aiogram использует первый подходящий обработчик, и второй никогда не вызывался.
2. **`handle_text_message()` (строка 663)** - дублирующий обработчик:
- Декоратор: `@router.message(F.text, StateFilter(ChatStates.in_chat))`
- Функция: содержала вся логика для рассылки сообщений
- **ПРОБЛЕМА**: эта функция НИКОГДА не вызывалась, потому что первый обработчик `check_exit_keywords()` перехватывал все текстовые сообщения.
## ✅ Сделанные исправления
### Исправление 1: Изменена логика получения активных пользователей
```python
# ДО (неправильно):
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
users = await UserService.get_all_users(session)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
# ПОСЛЕ (правильно):
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
users = await UserService.get_all_users(session)
return users
```
### Исправление 2: Объединены дублирующиеся обработчики
- **Объединена вся логика обработки сообщений в `check_exit_keywords()`** (теперь переименована концептуально, но осталась в коде)
- **Удален дублирующий обработчик `handle_text_message()`**
- Новая логика:
1. Проверяются ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
2. **Если это не ключевое слово** → продолжается обработка как обычного сообщения чата
3. Выполняется полная логика рассылки/пересылки
### Исправление 3: Добавлено логирование для отладки
Добавлены логи в `broadcast_message_with_scheduler()`:
```python
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
```
## 📊 Измененные файлы
- **src/handlers/chat_handlers.py**:
- Строка 189-192: Функция `get_all_active_users()` теперь возвращает **всех** пользователей
- Строка 140-358: Объединена вся логика обработки текстовых сообщений в функцию `check_exit_keywords()`
- Строка 663-857: **Удален** дублирующий обработчик `handle_text_message()`
## 🧪 Тестирование
### Инструкции для тестирования:
1. **Убедитесь, что есть минимум 2 пользователя в системе** (заказывали с 7 пользователями)
2. **Первый пользователь**: отправляет `/chat` или нажимает "Войти в чат"
3. **Второй пользователь**: отправляет `/chat` или нажимает "Войти в чат"
4. **Первый пользователь**: отправляет текстовое сообщение в чат
5. **Второй пользователь**: должен **получить сообщение** с заголовком типа:
- Для админов: `📨 Сообщение от [nickname] (карта: XXXX):`
- Для обычных пользователей: `📨 [nickname]:`
### Проверка логов:
```bash
docker compose logs -f bot | grep "\[CHAT\]"
```
Должны быть строки:
- `[CHAT] check_exit_keywords вызван для обработки: user=...`
- `[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: N`
- `[CHAT] После исключения отправителя: N пользователей`
- `[CHAT] broadcast_message_with_scheduler завершена: успешно=N, ошибок=M`
## 🎯 Ожидаемый результат
После применения этого исправления:
Все пользователи будут получать сообщения в чате
✅ Сообщения будут рассылаться **независимо от статуса регистрации**
✅ Логирование позволит отследить проблемы при возникновении
✅ Система корректно проверяет ключевые слова для выхода из чата
✅ Сообщения рассылаются **всем** пользователям, включая незарегистрированных