Harden bot against runtime stalls

This commit is contained in:
Андрей Цой
2026-07-01 20:35:50 +09:00
parent 6fbe4c4f43
commit 26c65b7caa
18 changed files with 645 additions and 337 deletions

View File

@@ -15,7 +15,8 @@ class AccountParticipationService:
async def add_account_to_lottery(
session: AsyncSession,
lottery_id: int,
account_number: str
account_number: str,
commit: bool = True,
) -> Dict[str, Any]:
"""
Добавить счет в розыгрыш.
@@ -91,7 +92,10 @@ class AccountParticipationService:
account_id=account_record.id if account_record else None
)
session.add(participation)
await session.commit()
if commit:
await session.commit()
else:
await session.flush()
card_info = f" (карта: {card_number})" if card_number else ""
return {
@@ -120,7 +124,7 @@ class AccountParticipationService:
for account in account_numbers:
result = await AccountParticipationService.add_account_to_lottery(
session, lottery_id, account
session, lottery_id, account, commit=False
)
if result["success"]:
@@ -132,6 +136,8 @@ class AccountParticipationService:
results["skipped_accounts"].append(account)
results["errors"].append(result["message"])
results["details"].append(f"{result['message']}")
await session.commit()
return results
@@ -139,7 +145,8 @@ class AccountParticipationService:
async def remove_account_from_lottery(
session: AsyncSession,
lottery_id: int,
account_number: str
account_number: str,
commit: bool = True,
) -> Dict[str, Any]:
"""Удалить счет из розыгрыша"""
formatted_account = format_account_number(account_number)
@@ -164,7 +171,10 @@ class AccountParticipationService:
}
await session.delete(participation)
await session.commit()
if commit:
await session.commit()
else:
await session.flush()
return {
"success": True,
@@ -216,13 +226,21 @@ class AccountParticipationService:
pattern: Паттерн поиска (например "11-22" или "33")
limit: Максимальное количество результатов
"""
# Получаем все счета розыгрыша
all_accounts = await AccountParticipationService.get_lottery_accounts(
session, lottery_id
digits_pattern = ''.join(c for c in pattern if c.isdigit())
if not digits_pattern:
return []
result = await session.execute(
select(Participation.account_number)
.where(
Participation.lottery_id == lottery_id,
Participation.account_number.isnot(None),
func.replace(Participation.account_number, '-', '').contains(digits_pattern),
)
.order_by(Participation.created_at.desc())
.limit(limit)
)
# Ищем совпадения
return search_accounts_by_pattern(pattern, all_accounts)[:limit]
return [account for account in result.scalars().all() if account]
@staticmethod
async def set_account_as_winner(

View File

@@ -1,6 +1,7 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
import asyncio
import logging
from aiogram import Router, F
from aiogram.types import (
@@ -24,6 +25,107 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
logger = logging.getLogger(__name__)
def _build_users_export_file(users_data: list[dict]) -> bytes:
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_num, user in enumerate(users_data, 2):
ws.cell(row=row_num, column=1, value=user["telegram_id"])
ws.cell(row=row_num, column=2, value=user["username"] or '')
ws.cell(row=row_num, column=3, value=user["first_name"] or '')
ws.cell(row=row_num, column=4, value=user["last_name"] or '')
ws.cell(row=row_num, column=5, value=user["nickname"] or '')
ws.cell(row=row_num, column=6, value=user["phone"] or '')
ws.cell(row=row_num, column=7, value=user["club_card_number"] or '')
ws.cell(row=row_num, column=8, value='Да' if user["is_registered"] else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user["is_admin"] else 'Нет')
ws.cell(row=row_num, column=10, value=user["verification_code"] or '')
ws.cell(row=row_num, column=11, value=user["created_at"] or '')
ws.cell(row=row_num, column=12, value=user["last_activity"] or '')
ws.cell(row=row_num, column=13, value='Да' if user["is_chat_banned"] else 'Нет')
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
excel_file = BytesIO()
wb.save(excel_file)
return excel_file.getvalue()
def _parse_users_xlsx(file_bytes: bytes) -> list[dict]:
from io import BytesIO
from openpyxl import load_workbook
wb = load_workbook(BytesIO(file_bytes), read_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
return []
headers = [h if h else '' for h in rows[0]]
telegram_id_idx = headers.index('Telegram ID')
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]:
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
user_dict[field_name] = (
value in ['Да', 'Yes', 'True', True, 1]
if field_name == 'is_registered'
else value if value else None
)
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
return users_data
async def safe_edit_message(
callback: CallbackQuery,
text: str,
@@ -980,12 +1082,10 @@ async def list_all_participants(callback: CallbackQuery):
async with async_session_maker() as session:
users = await UserService.get_all_users(session, limit=50)
# Получаем статистику для каждого пользователя
user_stats = []
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
user_stats = [(user, stats_by_user[user.id]) for user in users]
if not user_stats:
await callback.message.edit_text(
@@ -1116,6 +1216,9 @@ async def export_participants_data(callback: CallbackQuery):
async with async_session_maker() as session:
users = await UserService.get_all_users(session)
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
export_data = {
"timestamp": datetime.now().isoformat(),
@@ -1124,7 +1227,7 @@ async def export_participants_data(callback: CallbackQuery):
}
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
stats = stats_by_user[user.id]
user_data = {
"id": user.id,
"telegram_id": user.telegram_id,
@@ -1192,12 +1295,10 @@ async def process_search_participants(message: Message, state: FSMContext):
async with async_session_maker() as session:
users = await UserService.search_users(session, search_term)
# Получаем статистику для найденных пользователей
user_stats = []
for user in users:
stats = await ParticipationService.get_participant_stats(session, user.id)
user_stats.append((user, stats))
stats_by_user = await ParticipationService.get_participant_stats_bulk(
session, [user.id for user in users]
)
user_stats = [(user, stats_by_user[user.id]) for user in users]
await state.clear()
@@ -3556,20 +3657,10 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await session.commit()
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
# Отправляем уведомления победителям
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(callback.bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
# Отправляем результаты розыгрыша всем участникам (кроме победителей)
try:
await _notify_all_participants_about_results(callback.bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}")
asyncio.create_task(
_send_draw_notifications_background(callback.bot, lottery_id, winners_dict)
)
logger.info(f"Фоновая рассылка итогов запущена для розыгрыша {lottery_id}")
# Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id)
@@ -4484,6 +4575,24 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок")
async def _send_draw_notifications_background(bot, lottery_id: int, winners_dict: dict):
"""Отправить уведомления по итогам розыгрыша вне callback handler."""
async with async_session_maker() as session:
from ..utils.notifications import notify_winners_async
try:
await notify_winners_async(bot, session, lottery_id)
logger.info(f"Уведомления отправлены для розыгрыша {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}", exc_info=True)
try:
await _notify_all_participants_about_results(bot, session, lottery_id, winners_dict)
logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}")
except Exception as e:
logger.error(f"Ошибка при рассылке результатов: {e}", exc_info=True)
# ============================================================================
# ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ
# ============================================================================
@@ -4498,87 +4607,45 @@ async def admin_export_users(callback: CallbackQuery):
await callback.answer("⏳ Формирую файл...", show_alert=False)
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from aiogram.types import BufferedInputFile
async with async_session_maker() as session:
# Получаем всех пользователей
all_users = await UserService.get_all_users(session)
# Создаем Excel файл
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
# Заголовки
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
# Стиль для заголовков
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# Данные пользователей
for row_num, user in enumerate(all_users, 2):
ws.cell(row=row_num, column=1, value=user.telegram_id)
ws.cell(row=row_num, column=2, value=user.username or '')
ws.cell(row=row_num, column=3, value=user.first_name or '')
ws.cell(row=row_num, column=4, value=user.last_name or '')
ws.cell(row=row_num, column=5, value=user.nickname or '')
ws.cell(row=row_num, column=6, value=user.phone or '')
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
ws.cell(row=row_num, column=10, value=user.verification_code or '')
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем в BytesIO
excel_file = BytesIO()
wb.save(excel_file)
excel_file.seek(0)
# Отправляем файл
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file = BufferedInputFile(excel_file.read(), filename=filename)
registered_count = len([u for u in all_users if u.is_registered])
await callback.message.answer_document(
document=file,
caption=(
f"📥 <b>Экспорт пользователей</b>\n\n"
f"📊 Всего пользователей: {len(all_users)}\n"
f"✅ Зарегистрировано: {registered_count}\n"
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
),
parse_mode="HTML"
)
users_data = [
{
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"nickname": user.nickname,
"phone": user.phone,
"club_card_number": user.club_card_number,
"is_registered": user.is_registered,
"is_admin": user.is_admin,
"verification_code": user.verification_code,
"created_at": user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '',
"last_activity": user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '',
"is_chat_banned": user.is_chat_banned,
}
for user in all_users
]
excel_bytes = await asyncio.to_thread(_build_users_export_file, users_data)
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file = BufferedInputFile(excel_bytes, filename=filename)
registered_count = sum(1 for user in all_users if user.is_registered)
await callback.message.answer_document(
document=file,
caption=(
f"📥 <b>Экспорт пользователей</b>\n\n"
f"📊 Всего пользователей: {len(all_users)}\n"
f"✅ Зарегистрировано: {registered_count}\n"
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
),
parse_mode="HTML"
)
await callback.answer("✅ Файл отправлен", show_alert=False)
except Exception as e:
@@ -4633,69 +4700,21 @@ async def admin_import_users_process(message: Message, state: FSMContext):
status_msg = await message.answer("⏳ Загружаю файл...")
try:
from openpyxl import load_workbook
from io import BytesIO
# Скачиваем файл
file = await message.bot.get_file(message.document.file_id)
file_content = await message.bot.download_file(file.file_path)
# Читаем Excel файл
excel_file = BytesIO(file_content.read())
wb = load_workbook(excel_file, read_only=True)
ws = wb.active
# Читаем данные
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
# Первая строка - заголовки
headers = [h if h else '' for h in rows[0]]
# Находим индекс колонки Telegram ID
try:
telegram_id_idx = headers.index('Telegram ID')
users_data = await asyncio.to_thread(_parse_users_xlsx, file_content.read())
except ValueError:
await status_msg.edit_text("Не найдена обязательная колонка 'Telegram ID'.")
await state.clear()
return
# Создаем маппинг индексов для других полей
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]: # Пропускаем заголовки
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
if field_name == 'is_registered':
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
else:
user_dict[field_name] = value if value else None
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
if not users_data:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
await status_msg.edit_text(
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
@@ -4704,26 +4723,39 @@ async def admin_import_users_process(message: Message, state: FSMContext):
# Импортируем пользователей
async with async_session_maker() as session:
from sqlalchemy import select
added_count = 0
updated_count = 0
error_count = 0
normalized_users = []
for user_data in users_data:
telegram_id = user_data.get('telegram_id')
if not telegram_id:
error_count += 1
continue
try:
user_data['telegram_id'] = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
normalized_users.append(user_data)
existing_result = await session.execute(
select(User).where(User.telegram_id.in_([u['telegram_id'] for u in normalized_users]))
)
existing_by_tg_id = {
user.telegram_id: user
for user in existing_result.scalars().all()
}
for user_data in normalized_users:
try:
telegram_id = user_data.get('telegram_id')
if not telegram_id:
error_count += 1
continue
# Преобразуем telegram_id в int если это строка
try:
telegram_id = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
# Ищем существующего пользователя
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
existing_user = existing_by_tg_id.get(telegram_id)
if existing_user:
# Обновляем существующего

View File

@@ -1,6 +1,8 @@
"""Обработчики пользовательских сообщений в чате"""
import logging
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.exceptions import TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter, Command
@@ -23,6 +25,9 @@ from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS
from src.utils.account_utils import parse_accounts_from_message
logger = logging.getLogger(__name__)
MAX_RETRY_AFTER_SECONDS = 30
class ChatStates(StatesGroup):
"""Состояния для работы в чате"""
@@ -478,7 +483,7 @@ async def broadcast_message_with_scheduler(
return forwarded_ids, success_count, fail_count
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
async def _send_message_to_user(message: Message, user_telegram_id: int, retry: bool = True) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
@@ -486,12 +491,24 @@ async def _send_message_to_user(message: Message, user_telegram_id: int) -> Opti
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except TelegramRetryAfter as e:
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS:
logger.warning(f"FloodWait при отправке {user_telegram_id}: {e.retry_after}с")
await asyncio.sleep(e.retry_after + 1)
return await _send_message_to_user(message, user_telegram_id, retry=False)
logger.warning(f"Пропускаем отправку {user_telegram_id}: FloodWait {e.retry_after}с")
return None
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
logger.warning(f"Не удалось отправить сообщение {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]:
async def _send_message_to_user_with_sender(
message: Message,
user_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
"""
Отправить сообщение обычному пользователю с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
@@ -565,12 +582,26 @@ async def _send_message_to_user_with_sender(message: Message, user_telegram_id:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except TelegramRetryAfter as e:
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS:
logger.warning(f"FloodWait при отправке {user_telegram_id}: {e.retry_after}с")
await asyncio.sleep(e.retry_after + 1)
return await _send_message_to_user_with_sender(
message, user_telegram_id, sender_info, retry=False
)
logger.warning(f"Пропускаем отправку {user_telegram_id}: FloodWait {e.retry_after}с")
return None
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
logger.warning(f"Не удалось отправить сообщение {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]:
async def _send_message_to_admin_with_sender(
message: Message,
admin_telegram_id: int,
sender_info: str,
retry: bool = True,
) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
Возвращает message_id при успехе или None при ошибке.
@@ -644,8 +675,17 @@ async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except TelegramRetryAfter as e:
if retry and e.retry_after <= MAX_RETRY_AFTER_SECONDS:
logger.warning(f"FloodWait при отправке админу {admin_telegram_id}: {e.retry_after}с")
await asyncio.sleep(e.retry_after + 1)
return await _send_message_to_admin_with_sender(
message, admin_telegram_id, sender_info, retry=False
)
logger.warning(f"Пропускаем отправку админу {admin_telegram_id}: FloodWait {e.retry_after}с")
return None
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
logger.warning(f"Не удалось отправить сообщение админу {admin_telegram_id}: {e}")
return None
@@ -656,7 +696,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
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}")
logger.warning(f"Не удалось переслать сообщение в канал {channel_id}: {e}")
return False, None
@@ -992,7 +1032,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
await message.answer("✅ Стикер переслан в канал")
@router.message(F.voice)
@router.message(F.voice, StateFilter(ChatStates.in_chat))
async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
await message.answer(
@@ -1002,7 +1042,7 @@ async def handle_voice_message(message: Message):
return
@router.message(F.audio)
@router.message(F.audio, StateFilter(ChatStates.in_chat))
async def handle_audio_message(message: Message):
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
await message.answer(

View File

@@ -7,6 +7,7 @@ from src.filters.case_insensitive import CaseInsensitiveCommand
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from src.core.p2p_services import P2PMessageService
@@ -124,9 +125,13 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
await callback.answer()
async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей кроме себя
users = await UserService.get_all_users(session)
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
result = await session.execute(
select(User)
.where(User.telegram_id != callback.from_user.id, User.is_registered == True)
.order_by(User.created_at.desc())
.limit(20)
)
users = result.scalars().all()
if not users:
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
@@ -134,7 +139,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
# Создаём кнопки с пользователями (по 1 на строку)
buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице
for user in users:
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
if user.club_card_number:
display_name += f" (карта: {user.club_card_number})"