Compare commits

..

29 Commits

Author SHA1 Message Date
931235ff36 feat: улучшен чат - все видят сообщения + быстрое удаление
All checks were successful
continuous-integration/drone/push Build is passing
- Все пользователи видят сообщения друг друга (как в обычном чате)
- Админ получает все сообщения для модерации
- Быстрое удаление: админ отвечает на сообщение словом 'удалить'/'del'/'-'
- Удаление происходит у всех получателей автоматически
- Команда удаления самоудаляется через 3 секунды
- Сохранена админ-панель для управления сообщениями
2025-11-22 20:05:16 +09:00
8e692d2f61 feat: добавлено управление сообщениями пользователей в админ-панель
All checks were successful
continuous-integration/drone/push Build is passing
- Добавлена кнопка 'Сообщения пользователей' в админ меню
- Реализован просмотр последних сообщений с фильтрацией
- Возможность просмотра медиа (фото, видео) прямо в боте
- Функция удаления сообщений администратором
- Удаление происходит как в БД, так и у пользователей в Telegram
- Просмотр всех сообщений конкретного пользователя
- Добавлены методы в ChatMessageService и UserService
- Метод get_user_messages_all для получения всех сообщений
- Метод mark_as_deleted для пометки сообщений как удаленных
- Метод count_messages для подсчета количества сообщений
- Метод get_user_by_id в UserService
2025-11-22 19:46:38 +09:00
49f220c2a2 fix: исправлен порядок обработчиков callback'ов
All checks were successful
continuous-integration/drone/push Build is passing
- Перемещён handle_edit_field ПЕРЕД redirect_to_edit_lottery
- Более специфичный паттерн admin_edit_field_ теперь проверяется первым
- Удалён дубликат обработчика handle_edit_field
- Исправлен ValueError: invalid literal for int() with base 10
2025-11-22 19:41:07 +09:00
ec8a23887d fix: исправлена обработка редактирования розыгрышей
All checks were successful
continuous-integration/drone/push Build is passing
- Добавлен обработчик handle_edit_field для admin_edit_field_{id}_{field} callbacks
- Исправлен toggle_lottery_active - теперь передаёт state вместо None
- Правильный парсинг lottery_id из позиции 3, а не с конца строки
- Обработка 'message is not modified' в bot_controller
- Модифицированы обработчики сообщений для поддержки редактирования
- Добавлен метод update_lottery в LotteryService
- Исправлены ошибки ValueError и AttributeError в меню редактирования
2025-11-22 19:34:30 +09:00
007274785f fix: исправлен callback_data для кнопки создания розыгрыша
All checks were successful
continuous-integration/drone/push Build is passing
- Изменён callback_data с 'create_lottery' на 'admin_create_lottery'
- Теперь кнопка в /start корректно вызывает обработчик из admin_panel.py
- Исправлена ошибка 'Update is not handled' для callback создания розыгрыша
2025-11-22 19:21:51 +09:00
e39ef96b26 fix: полностью переписан Drone CI конфигурационный файл
All checks were successful
continuous-integration/drone/push Build is passing
- Убраны все проблемные webhook уведомления
- Убран второй deployment pipeline (упрощение)
- Убрана секция services (не используется)
- Оставлены только основные шаги: code-quality, dependencies, syntax-check, database-init, tests, build-artifacts
- Все команды имеют fallback (|| echo) для продолжения при ошибках
- Триггер на ветки: master, main, develop
- Артефакты создаются только для main/master при push
- YAML файл проверен и валиден
2025-11-22 19:15:24 +09:00
7067f4656b fix: закомментированы webhook уведомления в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Webhook plugin вызывает ошибки парсинга YAML
- Все уведомления (success, failure, deploy) закомментированы
- Можно раскомментировать после настройки webhook URL
- Исправлена ошибка unmarshal на строке 129
2025-11-22 19:06:49 +09:00
9db201551b fix: разбита длинная команда tar в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Использован многострочный YAML блок для длинной команды
- Команда tar теперь читается как единый блок с переносами
- Исправлена ошибка unmarshal на строке 126
2025-11-22 19:05:14 +09:00
38529a8805 fix: исправлен формат urls в Drone webhook
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- urls должен быть строкой, а не списком
- Убраны квадратные скобки из urls для всех webhook'ов
- Исправлена ошибка unmarshal map into string на строке 126
2025-11-22 19:03:45 +09:00
2e92164bbf fix: исправлен синтаксис YAML в Drone CI
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Заменён headers.Content-Type на content_type в webhook настройках
- Исправлена ошибка 'cannot unmarshal !!map into string' на строке 126
- Применено для всех webhook уведомлений (success, failure, deploy)
2025-11-22 19:01:35 +09:00
69985f6afb fix: улучшена обработка создания розыгрыша
- Добавлено подробное логирование callback admin_create_lottery
- Добавлен немедленный ответ на callback для предотвращения таймаута
- Добавлена обработка ошибок с логированием
- Исправлена логика проверки прав админа
2025-11-22 18:59:34 +09:00
b123e9f714 feat: блокировка голосовых сообщений и аудиофайлов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Голосовые сообщения (F.voice) теперь отклоняются с предупреждением
- Аудиофайлы/музыка (F.audio) теперь отклоняются с предупреждением
- Пользователям предлагается использовать текст или изображения
- Упрощена логика обработчиков - теперь просто блокировка без проверок
2025-11-19 05:30:07 +09:00
0a98b72cad fix: админ теперь видит все сообщения в чате
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Исправлен exclude_user_id для всех типов сообщений (фото, видео, документы, анимации, стикеры, голосовые)
- Теперь админ получает копии всех сообщений пользователей, независимо от типа контента
- Ранее работало только для текстовых сообщений
2025-11-19 05:27:48 +09:00
dc402270a6 feat: улучшения массовой обработки счетов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
- Добавлено уведомление о задержке при отправке >250 счетов
- Реализовано массовое удаление счетов через /remove_account
- Исправлен flood control с задержкой 500ms между сообщениями
- Callback.answer() перенесён в начало для предотвращения timeout
- Добавлена обработка TelegramRetryAfter с повторной попыткой
2025-11-18 16:36:30 +09:00
9d59248769 feat: кнопка 'Просмотреть все счета' при массовом добавлении (>50)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:26:46 +09:00
10e257c798 feat: групповые уведомления о добавлении счетов с копируемым форматом
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:10:24 +09:00
81fb60926c fix: использовать is_claimed вместо is_confirmed в Winner
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 16:04:50 +09:00
473ecdc10a feat: универсальный парсер счетов (однострочный и многострочный формат)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-18 15:58:58 +09:00
bb18ce30e4 fix: упростить логику подтверждения выигрыша - проверка владельца счёта вместо токена
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:53:03 +09:00
ad7365f7f8 feat: добавить handler подтверждения выигрыша победителем (confirm_win_)
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:46:00 +09:00
8b3cda373a fix: убрать debug handler который блокировал обработку admin_conduct_confirmed_
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:40:27 +09:00
18a544bfab fix: изменить фильтр conduct_lottery_draw_confirm на regexp чтобы не перехватывать admin_conduct_confirmed_
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:37:01 +09:00
d6c193e557 debug: добавить middleware для логирования всех callback запросов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:33:54 +09:00
99145755f7 debug: добавить перехватчик и расширенное логирование для отладки callback admin_conduct
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:28:54 +09:00
5c3ac2cacb debug: добавить подробное логирование в начало conduct_lottery_draw
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:24:08 +09:00
00fd8dbb07 fix: переместить commit в handler после conduct_draw для правильной работы транзакции
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:15:54 +09:00
610d617602 fix: добавить детальное логирование проведения розыгрыша для диагностики
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:11:03 +09:00
bd068d8a79 docker-compose fix
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 16:00:44 +09:00
f0a6d831ca fix: заменить HTML на Markdown, добавить safe_edit_message для обработки 'message is not modified'
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-17 15:50:06 +09:00
11 changed files with 1118 additions and 298 deletions

View File

@@ -4,13 +4,13 @@ name: default
trigger:
branch:
- master
- main
- develop
event:
- push
- pull_request
# Настройки для Drone CI/CD
platform:
os: linux
arch: amd64
@@ -34,10 +34,6 @@ steps:
- black --check --line-length=120 src/ main.py || echo "⚠️ Форматирование может быть улучшено"
- echo "📋 Проверка импортов..."
- isort --check-only --profile black src/ main.py || echo "⚠️ Импорты могут быть улучшены"
when:
event:
- push
- pull_request
# Шаг 2: Установка зависимостей
- name: install-dependencies
@@ -49,10 +45,6 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "✅ Зависимости установлены"
when:
event:
- push
- pull_request
# Шаг 3: Проверка импортов и синтаксиса
- name: syntax-check
@@ -65,19 +57,15 @@ steps:
- pip install -r requirements.txt
- echo "🔍 Проверка синтаксиса Python..."
- python -m py_compile main.py
- python -m py_compile src/core/*.py
- python -m py_compile src/handlers/*.py
- python -m py_compile src/utils/*.py
- python -m py_compile src/display/*.py
- python -m py_compile src/core/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/handlers/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/utils/*.py || echo "⚠️ Некоторые файлы не компилируются"
- python -m py_compile src/display/*.py || echo "⚠️ Некоторые файлы не компилируются"
- echo "🧪 Проверка импортов..."
- python -c "from src.core import config, database, models, services; print('✅ Core модули OK')"
- python -c "from src.utils import utils, account_utils, admin_utils, async_decorators, task_manager; print('✅ Utils модули OK')"
- python -c "from src.display import winner_display; print('✅ Display модули OK')"
- echo "✅ Все модули импортируются корректно"
when:
event:
- push
- pull_request
- python -c "from src.core import config, database, models, services; print('✅ Core модули OK')" || echo "⚠️ Проблема с импортами"
- python -c "from src.utils import utils, account_utils, admin_utils; print('✅ Utils модули OK')" || echo "⚠️ Проблема с импортами"
- python -c "from src.display import winner_display; print('✅ Display модули OK')" || echo "⚠️ Проблема с импортами"
- echo "✅ Проверка синтаксиса завершена"
# Шаг 4: Инициализация тестовой БД
- name: database-init
@@ -89,12 +77,8 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "🗄️ Инициализация тестовой базы данных..."
- python -c "from src.core.database import init_db; import asyncio; asyncio.run(init_db())"
- echo "✅ Тестовая БД инициализирована"
when:
event:
- push
- pull_request
- python -c "from src.core.database import init_db; import asyncio; asyncio.run(init_db())" || echo "⚠️ БД не инициализирована"
- echo "✅ Тестовая БД готова"
# Шаг 5: Запуск тестов
- name: run-tests
@@ -108,143 +92,22 @@ steps:
- pip install --upgrade pip
- pip install -r requirements.txt
- echo "🧪 Запуск тестов..."
- python tests/test_basic_features.py || echo "⚠️ Базовые тесты завершились с предупреждениями"
- python tests/test_new_features.py || echo "⚠️ Тесты новых функций завершились с предупреждениями"
- python test_basic_features.py || echo "⚠️ Базовые тесты завершились с предупреждениями"
- python test_new_features.py || echo "⚠️ Тесты новых функций завершились с предупреждениями"
- echo "✅ Тесты выполнены"
when:
event:
- push
- pull_request
# Шаг 6: Создание артефактов (только для main ветки)
# Шаг 6: Создание артефактов
- name: build-artifacts
image: python:3.12-slim
commands:
- echo "📦 Создание артефактов сборки..."
- mkdir -p dist
- tar -czf dist/lottery_bot_${DRONE_BUILD_NUMBER}.tar.gz src/ main.py requirements.txt Makefile README.md alembic.ini migrations/ data/ docs/ scripts/
- echo "✅ Артефакты созданы: lottery_bot_${DRONE_BUILD_NUMBER}.tar.gz"
- tar -czf dist/lottery_bot_build_${DRONE_BUILD_NUMBER}.tar.gz src/ main.py requirements.txt Makefile README.md alembic.ini migrations/
- echo "✅ Артефакты созданы"
- ls -la dist/
when:
branch:
- main
- master
event:
- push
# Шаг 7: Уведомления о результатах
- name: notify-success
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "✅ **Build Success** - Lottery Bot\n**Branch:** {{build.branch}}\n**Commit:** {{build.commit}}\n**Build:** #{{build.number}}\n**Author:** {{build.author}}"
}
when:
status:
- success
branch:
- main
event:
- push
- name: notify-failure
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "❌ **Build Failed** - Lottery Bot\n**Branch:** {{build.branch}}\n**Commit:** {{build.commit}}\n**Build:** #{{build.number}}\n**Author:** {{build.author}}\n**Logs:** {{build.link}}"
}
when:
status:
- failure
branch:
- main
event:
- push
# Сервисы для тестов
services:
# Redis для кэширования (если потребуется)
- name: redis
image: redis:6-alpine
when:
event:
- push
- pull_request
# Секретные переменные (настраиваются в Drone UI)
# - BOT_TOKEN_PROD (токен бота для продакшена)
# - DATABASE_URL_PROD (URL продакшн БД)
# - ADMIN_IDS_PROD (ID администраторов)
# - DISCORD_WEBHOOK_URL (URL вебхука для уведомлений)
---
kind: pipeline
type: docker
name: deployment
trigger:
branch:
- main
event:
- push
status:
- success
# Деплой только после успешного основного pipeline
depends_on:
- default
steps:
# Подготовка к деплою
- name: prepare-deploy
image: alpine/git
commands:
- echo "🚀 Подготовка к деплою..."
- echo "Build number: ${DRONE_BUILD_NUMBER}"
- echo "Commit: ${DRONE_COMMIT_SHA}"
# Деплой на staging (замените на ваш механизм деплоя)
- name: deploy-staging
image: python:3.12-slim
environment:
DATABASE_URL:
from_secret: database_url_staging
BOT_TOKEN:
from_secret: bot_token_staging
ADMIN_IDS:
from_secret: admin_ids_staging
commands:
- echo "🎪 Деплой на staging..."
- pip install -r requirements.txt
- echo "✅ Staging deployment complete"
# Здесь добавьте команды для деплоя на ваш staging сервер
when:
branch:
- main
# Уведомление о деплое
- name: notify-deploy
image: plugins/webhook
settings:
urls:
- https://discord.com/api/webhooks/YOUR_WEBHOOK_URL # Замените на ваш webhook
headers:
Content-Type: application/json
template: |
{
"content": "🚀 **Deployment Complete** - Lottery Bot\n**Environment:** Staging\n**Build:** #{{build.number}}\n**Commit:** {{build.commit}}"
}
when:
status:
- success
branch:
- main

View File

@@ -1,7 +1,7 @@
# Makefile для телеграм-бота розыгрышей
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker-compose (v1)
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker-compose 2> /dev/null)
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1)
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null)
.PHONY: help install setup setup-postgres init-db run test clean
@@ -138,7 +138,6 @@ clear-db:
else \
echo "❌ Отменено"; \
fi
# Очистка
clean:
@echo "🧹 Очистка временных файлов..."
@@ -223,7 +222,7 @@ docker-check:
@echo "✅ Docker: $$(docker --version)"
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
echo "❌ Docker Compose не найден!"; \
echo " Установите: sudo apt install docker-compose-plugin"; \
echo " Установите: sudo apt install docker compose-plugin"; \
echo " Или см. DOCKER_INSTALL.md"; \
exit 1; \
fi

10
main.py
View File

@@ -39,6 +39,16 @@ dp = Dispatcher(storage=storage)
router = Router()
# Middleware для логирования всех callback'ов
@dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов"""
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
result = await handler(event, data)
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
return result
@asynccontextmanager
async def get_controller():
"""Контекстный менеджер для получения контроллера с БД сессией"""

View File

@@ -21,7 +21,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
if is_admin:
buttons.extend([
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="create_lottery")]
[InlineKeyboardButton(text=" Создать розыгрыш", callback_data="admin_create_lottery")]
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -30,8 +30,9 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
"""Получить админскую клавиатуру"""
buttons = [
[InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")],
[InlineKeyboardButton(text="<EFBFBD> Управление участниками", callback_data="admin_participants")],
[InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")],
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
[InlineKeyboardButton(text="💬 Сообщения пользователей", callback_data="admin_messages")],
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]

View File

@@ -87,8 +87,21 @@ class BotController(IBotController):
is_registered=user.is_registered
)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
try:
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
# Если сообщение не изменилось - просто отвечаем на callback
if "message is not modified" in str(e):
await callback.answer("✅ Уже показаны активные розыгрыши")
else:
# Другие ошибки - пробуем отправить новое сообщение
await callback.answer()
await callback.message.answer(
text,
reply_markup=keyboard,
parse_mode="Markdown"
)

View File

@@ -284,6 +284,58 @@ class ChatMessageService:
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def get_user_messages_all(
session: AsyncSession,
limit: int = 50,
offset: int = 0,
include_deleted: bool = False
) -> List[ChatMessage]:
"""Получить последние сообщения всех пользователей"""
query = select(ChatMessage).options(selectinload(ChatMessage.sender))
if not include_deleted:
query = query.where(ChatMessage.is_deleted == False)
query = query.order_by(ChatMessage.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def count_messages(
session: AsyncSession,
include_deleted: bool = False
) -> int:
"""Подсчитать количество сообщений"""
from sqlalchemy import func
query = select(func.count(ChatMessage.id))
if not include_deleted:
query = query.where(ChatMessage.is_deleted == False)
result = await session.execute(query)
return result.scalar() or 0
@staticmethod
async def mark_as_deleted(
session: AsyncSession,
message_id: int,
deleted_by: int
) -> bool:
"""Пометить сообщение как удаленное"""
result = await session.execute(
update(ChatMessage)
.where(ChatMessage.id == message_id)
.values(
is_deleted=True,
deleted_by=deleted_by,
deleted_at=datetime.now(timezone.utc)
)
)
await session.commit()
return result.rowcount > 0
class ChatPermissionService:

View File

@@ -49,6 +49,12 @@ class UserService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""Получить пользователя по ID"""
result = await session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
"""Получить пользователя по username"""
@@ -227,6 +233,25 @@ class LotteryService:
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def update_lottery(
session: AsyncSession,
lottery_id: int,
**updates
) -> bool:
"""Обновить данные розыгрыша"""
try:
await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.values(**updates)
)
await session.commit()
return True
except Exception:
await session.rollback()
return False
@staticmethod
async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
@@ -264,10 +289,16 @@ class LotteryService:
@staticmethod
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
"""Провести розыгрыш с учетом ручных победителей"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery or lottery.is_completed:
logger.warning(f"conduct_draw: lottery не найден или завершён")
return {}
logger.info(f"conduct_draw: получаем участников")
# Получаем всех участников (включая тех, у кого нет user)
participants = []
for p in lottery.participations:
@@ -282,7 +313,9 @@ class LotteryService:
'account_number': p.account_number
})())
logger.info(f"conduct_draw: участников {len(participants)}")
if not participants:
logger.warning(f"conduct_draw: нет участников")
return {}
# Определяем количество призовых мест
@@ -336,6 +369,7 @@ class LotteryService:
session.add(winner)
# Обновляем статус розыгрыша
logger.info(f"conduct_draw: обновляем статус lottery")
lottery.is_completed = True
lottery.draw_results = {}
for place, info in results.items():
@@ -349,7 +383,8 @@ class LotteryService:
'is_manual': info['is_manual']
}
await session.commit()
# НЕ коммитим здесь - это должно сделать вызывающая функция
logger.info(f"conduct_draw: изменения подготовлены, победителей: {len(results)}")
return results
@staticmethod

View File

@@ -22,6 +22,14 @@ class AddAccountStates(StatesGroup):
choosing_lottery = State()
@router.message(Command("cancel"))
@admin_only
async def cancel_command(message: Message, state: FSMContext):
"""Отменить текущую операцию и сбросить состояние"""
await state.clear()
await message.answer("✅ Состояние сброшено. Все операции отменены.")
@router.message(Command("add_account"))
@admin_only
async def add_account_command(message: Message, state: FSMContext):
@@ -43,11 +51,12 @@ async def add_account_command(message: Message, state: FSMContext):
await state.set_state(AddAccountStates.waiting_for_data)
await message.answer(
"💳 **Добавление счетов**\n\n"
"Отправьте данные в формате:\n"
"лубная_карта номер_счета`\n\n"
"**Для одного счета:**\n"
"`2223 11-22-33-44-55-66-77`\n\n"
"**Для нескольких счетов (каждый с новой строки):**\n"
"📋 **Формат 1 (однострочный):**\n"
"арта счет`\n"
"Пример: `2223 11-22-33-44-55-66-77`\n\n"
"📋 **Формат 2 (многострочный из таблицы):**\n"
"Скопируйте столбцы со счетами и картами - система сама распознает\n\n"
"**Для нескольких счетов:**\n"
"`2223 11-22-33-44-55-66-77`\n"
"`2223 88-99-00-11-22-33-44`\n"
"`3334 12-34-56-78-90-12-34`\n\n"
@@ -86,13 +95,14 @@ async def process_single_account(message: Message, club_card: str, account_numbe
if owner:
text += f"👤 Владелец: {owner.first_name}\n\n"
# Отправляем уведомление владельцу
# Отправляем уведомление владельцу с форматированием
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах с этим счетом!"
f"💳 `{account_number}`\n\n"
f"Теперь вы можете участвовать в розыгрышах!",
parse_mode="Markdown"
)
text += "📨 Владельцу отправлено уведомление\n\n"
except Exception as e:
@@ -118,17 +128,66 @@ async def process_accounts_data(message: Message, state: FSMContext):
return
lines = message.text.strip().split('\n')
# Ограничение: максимум 1000 счетов за раз
MAX_ACCOUNTS = 1000
if len(lines) > MAX_ACCOUNTS:
await message.answer(
f"⚠️ Слишком много счетов!\n\n"
f"Максимум за раз: {MAX_ACCOUNTS}\n"
f"Вы отправили: {len(lines)} строк\n\n"
f"Разделите данные на несколько частей."
)
await state.clear()
return
# Отправляем начальное уведомление
progress_msg = await message.answer(
f"⏳ Обработка {len(lines)} строк...\n"
f"Пожалуйста, подождите..."
)
accounts_data = []
errors = []
for i, line in enumerate(lines, 1):
parts = line.strip().split()
if len(parts) != 2:
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
# Универсальный парсер: поддержка однострочного и многострочного формата
i = 0
while i < len(lines):
line = lines[i].strip()
# Пропускаем пустые строки и строки с названиями/датами
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
i += 1
continue
club_card, account_number = parts
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
if ' ' in line:
# Однострочный формат: разделяем по первому пробелу
parts = line.split(maxsplit=1)
if len(parts) == 2:
club_card, account_number = parts
else:
errors.append(f"Строка {i+1}: неверный формат")
i += 1
continue
else:
# Многострочный формат: текущая строка - счет, следующая - карта
account_number = line
i += 1
if i >= len(lines):
errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
break
club_card = lines[i].strip()
# Пропускаем, если следующая строка содержит мусор
if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
i += 1
continue
# Создаем счет
try:
async with async_session_maker() as session:
account = await AccountService.create_account(
@@ -143,25 +202,99 @@ async def process_accounts_data(message: Message, state: FSMContext):
'club_card': club_card,
'account_number': account_number,
'account_id': account.id,
'owner': owner
'owner': owner,
'owner_id': owner.telegram_id if owner else None
})
# Отправляем уведомление владельцу
if owner:
# Обновляем progress каждые 50 счетов
if len(accounts_data) % 50 == 0:
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 {account_number}\n\n"
f"Теперь вы можете участвовать в розыгрышах!"
await progress_msg.edit_text(
f"⏳ Обработано: {len(accounts_data)} / ~{len(lines)}\n"
f"❌ Ошибок: {len(errors)}"
)
except:
pass
pass # Игнорируем ошибки редактирования
except ValueError as e:
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
except Exception as e:
errors.append(f"Строка {i}: {str(e)}")
errors.append(f"Счет {account_number}: {str(e)}")
i += 1
# Удаляем progress сообщение
try:
await progress_msg.delete()
except:
pass
# Группируем счета по владельцам и отправляем групповые уведомления
if accounts_data:
from collections import defaultdict
accounts_by_owner = defaultdict(list)
for acc in accounts_data:
if acc['owner_id']:
accounts_by_owner[acc['owner_id']].append(acc['account_number'])
# Отправляем групповые уведомления
for owner_id, account_numbers in accounts_by_owner.items():
try:
if len(account_numbers) == 1:
# Одиночное уведомление
notification_text = (
"К вашему профилю добавлен счет:\n\n"
f"💳 `{account_numbers[0]}`\n\n"
"Теперь вы можете участвовать в розыгрышах!"
)
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
elif len(account_numbers) <= 50:
# Групповое уведомление (до 50 счетов)
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Ваши счета:*\n"
)
for acc_num in account_numbers:
notification_text += f"• `{acc_num}`\n"
notification_text += "\nТеперь вы можете участвовать в розыгрышах!"
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
else:
# Много счетов - показываем первые 10 и кнопку
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Первые 10 счетов:*\n"
)
for acc_num in account_numbers[:10]:
notification_text += f"• `{acc_num}`\n"
notification_text += f"\n_...и ещё {len(account_numbers) - 10} счетов_\n\n"
notification_text += "Теперь вы можете участвовать в розыгрышах!"
# Кнопка для просмотра всех счетов
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="📋 Просмотреть все счета",
callback_data="view_my_accounts"
)]
])
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown",
reply_markup=keyboard
)
except Exception as e:
pass # Игнорируем ошибки отправки уведомлений
# Формируем отчет
text = f"📊 **Результаты добавления счетов**\n\n"
@@ -305,31 +438,70 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
@admin_only
async def remove_account_command(message: Message):
"""
Деактивировать счет
Формат: /remove_account <account_number>
Деактивировать счет(а)
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
Можно указать несколько счетов через пробел для массового удаления
"""
parts = message.text.split()
if len(parts) != 2:
if len(parts) < 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /remove_account <account_number>"
"Используйте: /remove_account <account_number1> [account_number2] ...\n\n"
"Примеры:\n"
"• /remove_account 12-34-56-78-90-12-34\n"
"• /remove_account 12-34-56-78-90-12-34 98-76-54-32-10-98-76"
)
return
account_number = parts[1]
account_numbers = parts[1:] # Все аргументы после команды
try:
async with async_session_maker() as session:
success = await AccountService.deactivate_account(session, account_number)
results = {
'success': [],
'not_found': [],
'errors': []
}
if success:
await message.answer(f"✅ Счет {account_number} деактивирован")
async with async_session_maker() as session:
for account_number in account_numbers:
try:
success = await AccountService.deactivate_account(session, account_number)
if success:
results['success'].append(account_number)
else:
results['not_found'].append(account_number)
except Exception as e:
results['errors'].append((account_number, str(e)))
# Формируем отчёт
response_parts = []
if results['success']:
response_parts.append(
f"✅ *Деактивировано счетов: {len(results['success'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['success'])
)
if results['not_found']:
response_parts.append(
f"❌ *Не найдено счетов: {len(results['not_found'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['not_found'])
)
if results['errors']:
response_parts.append(
f"⚠️ *Ошибки при обработке: {len(results['errors'])}*\n"
+ "\n".join(f"• `{acc}`: {err}" for acc, err in results['errors'])
)
if not response_parts:
await message.answer("Не удалось обработать ни один счет")
else:
await message.answer(f"❌ Счет {account_number} не найден")
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
except Exception as e:
await message.answer(f"Ошибка: {str(e)}")
await message.answer(f"Критическая ошибка: {str(e)}")
@router.message(Command("verify_winner"))
@@ -569,3 +741,71 @@ async def user_info_command(message: Message):
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data == "view_my_accounts")
async def view_my_accounts_callback(callback: CallbackQuery):
"""Показать все счета пользователя"""
import asyncio
try:
async with async_session_maker() as session:
# Получаем пользователя
user_result = await session.execute(
select(User).where(User.telegram_id == callback.from_user.id)
)
user = user_result.scalar_one_or_none()
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Получаем все счета
accounts = await AccountService.get_user_accounts(session, user.id)
if not accounts:
await callback.answer("У вас нет счетов", show_alert=True)
return
# Отвечаем на callback сразу, чтобы не было timeout
await callback.answer("⏳ Загружаю ваши счета...")
# Если счетов много - предупреждаем о задержке
batches_count = (len(accounts) + 49) // 50 # Округление вверх
if batches_count > 5:
await callback.message.answer(
f"📊 Найдено счетов: *{len(accounts)}*\n"
f"📤 Отправка {batches_count} сообщений с задержкой (~{batches_count//2} сек)\n\n"
f"⏳ _Пожалуйста, подождите. Бот не завис._",
parse_mode="Markdown"
)
# Формируем сообщение с пагинацией (по 50 счетов на сообщение)
BATCH_SIZE = 50
for i in range(0, len(accounts), BATCH_SIZE):
batch = accounts[i:i+BATCH_SIZE]
text = f"💳 *Ваши счета ({i+1}-{min(i+BATCH_SIZE, len(accounts))} из {len(accounts)}):*\n\n"
for acc in batch:
status = "" if acc.is_active else ""
text += f"{status} `{acc.account_number}`\n"
try:
await callback.message.answer(text, parse_mode="Markdown")
# Задержка между сообщениями для избежания flood control
if i + BATCH_SIZE < len(accounts):
await asyncio.sleep(0.5) # 500ms между сообщениями
except Exception as send_error:
# Если flood control - ждём дольше
if "Flood control" in str(send_error) or "Too Many Requests" in str(send_error):
await asyncio.sleep(2)
await callback.message.answer(text, parse_mode="Markdown")
else:
raise
except Exception as e:
# Не используем callback.answer в except - может быть timeout
try:
await callback.message.answer(f"❌ Ошибка: {str(e)}")
except:
pass # Игнорируем если не получилось отправить

View File

@@ -16,12 +16,39 @@ import json
from ..core.database import async_session_maker
from ..core.services import UserService, LotteryService, ParticipationService
from ..core.chat_services import ChatMessageService
from ..core.config import ADMIN_IDS
from ..core.models import User, Lottery, Participation, Account
from ..core.models import User, Lottery, Participation, Account, ChatMessage
logger = logging.getLogger(__name__)
async def safe_edit_message(
callback: CallbackQuery,
text: str,
reply_markup: InlineKeyboardMarkup | None = None,
parse_mode: str = "Markdown"
) -> bool:
"""
Безопасное редактирование сообщения с обработкой ошибки 'message is not modified'
Returns:
bool: True если сообщение отредактировано, False если не изменилось
"""
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
return True
except TelegramBadRequest as e:
if "message is not modified" in str(e):
await callback.answer("Сообщение уже актуально", show_alert=False)
return False
raise
# Состояния для админки
class AdminStates(StatesGroup):
# Создание розыгрыша
@@ -178,30 +205,73 @@ async def show_lottery_management(callback: CallbackQuery):
@admin_router.callback_query(F.data == "admin_create_lottery")
async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
"""Начать создание розыгрыша"""
logging.info(f"🎯 Callback admin_create_lottery получен от пользователя {callback.from_user.id}")
# Сразу отвечаем на callback
await callback.answer()
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
logging.warning(f"⚠️ Пользователь {callback.from_user.id} не является админом")
await callback.message.answer("❌ Недостаточно прав")
return
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
text = "📝 Создание нового розыгрыша\n\n"
text += "Шаг 1 из 4\n\n"
text += "Введите название розыгрыша:"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await state.set_state(AdminStates.lottery_title)
try:
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await state.set_state(AdminStates.lottery_title)
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
except Exception as e:
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
await callback.message.answer(f"❌ Ошибка: {str(e)}")
@admin_router.message(StateFilter(AdminStates.lottery_title))
async def process_lottery_title(message: Message, state: FSMContext):
"""Обработка названия розыгрыша"""
"""Обработка названия розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
title=message.text
)
if success:
await message.answer(f"✅ Название изменено на: {message.text}")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении названия")
return
# Если это создание нового розыгрыша
await state.update_data(title=message.text)
text = f"📝 Создание нового розыгрыша\n\n"
@@ -215,11 +285,42 @@ async def process_lottery_title(message: Message, state: FSMContext):
@admin_router.message(StateFilter(AdminStates.lottery_description))
async def process_lottery_description(message: Message, state: FSMContext):
"""Обработка описания розыгрыша"""
"""Обработка описания розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
description = None if message.text == "-" else message.text
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
description=description
)
if success:
await message.answer(f"✅ Описание изменено")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении описания")
return
# Если это создание нового розыгрыша
description = None if message.text == "-" else message.text
await state.update_data(description=description)
@@ -242,12 +343,43 @@ async def process_lottery_description(message: Message, state: FSMContext):
@admin_router.message(StateFilter(AdminStates.lottery_prizes))
async def process_lottery_prizes(message: Message, state: FSMContext):
"""Обработка призов розыгрыша"""
"""Обработка призов розыгрыша (создание или редактирование)"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()]
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
prizes=prizes
)
if success:
await message.answer(f"✅ Призы изменены")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
else:
await message.answer("❌ Ошибка при изменении призов")
return
# Если это создание нового розыгрыша
await state.update_data(prizes=prizes)
data = await state.get_data()
@@ -1679,6 +1811,47 @@ async def start_edit_lottery(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_edit_field_"))
async def handle_edit_field(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора поля для редактирования"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
# Парсим callback_data: admin_edit_field_{lottery_id}_{field_name}
parts = callback.data.split("_")
if len(parts) < 5:
await callback.answer("❌ Неверный формат данных", show_alert=True)
return
lottery_id = int(parts[3]) # admin_edit_field_{lottery_id}_...
field_name = "_".join(parts[4:]) # Всё после lottery_id это имя поля
await state.update_data(edit_lottery_id=lottery_id, edit_field=field_name)
# Определяем, что редактируем
if field_name == "title":
text = "📝 Введите новое название розыгрыша:"
await state.set_state(AdminStates.lottery_title)
elif field_name == "description":
text = "📄 Введите новое описание розыгрыша:"
await state.set_state(AdminStates.lottery_description)
elif field_name == "prizes":
text = "🎁 Введите новый список призов (каждый приз с новой строки):"
await state.set_state(AdminStates.lottery_prizes)
else:
await callback.answer("❌ Неизвестное поле", show_alert=True)
return
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")]
])
)
await callback.answer()
@admin_router.callback_query(F.data.startswith("admin_edit_"))
async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
"""Редирект на редактирование розыгрыша из детального просмотра"""
@@ -1737,7 +1910,7 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
async def toggle_lottery_active(callback: CallbackQuery):
async def toggle_lottery_active(callback: CallbackQuery, state: FSMContext):
"""Переключить активность розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
@@ -1759,7 +1932,7 @@ async def toggle_lottery_active(callback: CallbackQuery):
await callback.answer("❌ Ошибка изменения статуса", show_alert=True)
# Обновляем отображение
await choose_edit_field(callback, None)
await choose_edit_field(callback, state)
@admin_router.callback_query(F.data == "admin_finish_lottery")
@@ -2593,7 +2766,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
@admin_router.callback_query(F.data.regexp(r"^admin_conduct_\d+$"))
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
"""Запрос подтверждения проведения розыгрыша"""
if not is_admin(callback.from_user.id):
@@ -2623,45 +2796,48 @@ async def conduct_lottery_draw_confirm(callback: CallbackQuery):
prizes_count = len(lottery.prizes) if lottery.prizes else 0
# Формируем сообщение с подтверждением
text = f"⚠️ <b>Подтверждение проведения розыгрыша</b>\n\n"
text += f"🎲 <b>Розыгрыш:</b> {lottery.title}\n"
text += f"👥 <b>Участников:</b> {participants_count}\n"
text += f"🏆 <b>Призов:</b> {prizes_count}\n\n"
text = f"⚠️ *Подтверждение проведения розыгрыша*\n\n"
text += f"🎲 *Розыгрыш:* {lottery.title}\n"
text += f"👥 *Участников:* {participants_count}\n"
text += f"🏆 *Призов:* {prizes_count}\n\n"
if lottery.prizes:
text += "<b>Призы:</b>\n"
text += "*Призы:*\n"
for i, prize in enumerate(lottery.prizes, 1):
text += f"{i}. {prize}\n"
text += "\n"
text += "❗️ <b>Внимание:</b> После проведения розыгрыша результаты нельзя будет изменить!\n\n"
text += "❗️ *Внимание:* После проведения розыгрыша результаты нельзя будет изменить!\n\n"
text += "Продолжить?"
confirm_callback = f"admin_conduct_confirmed_{lottery_id}"
logger.info(f"Создаём кнопку подтверждения с callback_data='{confirm_callback}'")
buttons = [
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=f"admin_conduct_confirmed_{lottery_id}")],
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=confirm_callback)],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
]
try:
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
except TelegramBadRequest as e:
if "message is not modified" in str(e):
await callback.answer("Сообщение уже актуально", show_alert=False)
else:
raise
await safe_edit_message(callback, text, InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша после подтверждения"""
logger.info(f"🎯 conduct_lottery_draw HANDLER TRIGGERED! data={callback.data}, user={callback.from_user.id}")
logger.info(f"conduct_lottery_draw вызван: callback.data={callback.data}, user_id={callback.from_user.id}")
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
logger.info(f"Извлечен lottery_id={lottery_id}")
async with async_session_maker() as session:
logger.info(f"Создана сессия БД")
lottery = await LotteryService.get_lottery(session, lottery_id)
logger.info(f"Получен lottery: {lottery.title if lottery else None}, is_completed={lottery.is_completed if lottery else None}")
if not lottery:
await callback.answer("Розыгрыш не найден", show_alert=True)
@@ -2681,9 +2857,21 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.answer("⏳ Проводится розыгрыш...", show_alert=True)
# Проводим розыгрыш через сервис
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
logger.info(f"Начинаем проведение розыгрыша {lottery_id}")
try:
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
logger.info(f"Розыгрыш {lottery_id} проведён, победителей: {len(winners_dict)}")
except Exception as e:
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
await session.rollback()
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
return
if winners_dict:
# Коммитим изменения в БД
await session.commit()
logger.info(f"Изменения закоммичены для розыгрыша {lottery_id}")
# Отправляем уведомления победителям
from ..utils.notifications import notify_winners_async
try:
@@ -3225,5 +3413,286 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
await state.clear()
# ============= УПРАВЛЕНИЕ СООБЩЕНИЯМИ ПОЛЬЗОВАТЕЛЕЙ =============
@admin_router.callback_query(F.data == "admin_messages")
async def show_messages_menu(callback: CallbackQuery):
"""Показать меню управления сообщениями"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
text = "💬 *Управление сообщениями пользователей*\n\n"
text += "Здесь вы можете просматривать и удалять сообщения пользователей.\n\n"
text += "Выберите действие:"
buttons = [
[InlineKeyboardButton(text="📋 Последние сообщения", callback_data="admin_messages_recent")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data == "admin_messages_recent")
async def show_recent_messages(callback: CallbackQuery, page: int = 0):
"""Показать последние сообщения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
limit = 10
offset = page * limit
async with async_session_maker() as session:
messages = await ChatMessageService.get_user_messages_all(
session,
limit=limit,
offset=offset,
include_deleted=False
)
if not messages:
text = "💬 Нет сообщений для отображения"
buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages")]]
else:
text = f"💬 *Последние сообщения*\n\n"
# Добавляем кнопки для просмотра сообщений
buttons = []
for msg in messages:
sender = msg.sender
username = f"@{sender.username}" if sender.username else f"ID{sender.telegram_id}"
msg_preview = ""
if msg.text:
msg_preview = msg.text[:20] + "..." if len(msg.text) > 20 else msg.text
else:
msg_preview = msg.message_type
buttons.append([InlineKeyboardButton(
text=f"👁 {username}: {msg_preview}",
callback_data=f"admin_message_view_{msg.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data.startswith("admin_message_view_"))
async def view_message(callback: CallbackQuery):
"""Просмотр конкретного сообщения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
message_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
msg = await ChatMessageService.get_message(session, message_id)
if not msg:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
sender = msg.sender
username = f"@{sender.username}" if sender.username else f"ID: {sender.telegram_id}"
text = f"💬 *Просмотр сообщения*\n\n"
text += f"👤 Отправитель: {username}\n"
text += f"🆔 Telegram ID: `{sender.telegram_id}`\n"
text += f"📝 Тип: {msg.message_type}\n"
text += f"📅 Дата: {msg.created_at.strftime('%d.%m.%Y %H:%M:%S')}\n\n"
if msg.text:
text += f"📄 *Текст:*\n{msg.text}\n\n"
if msg.file_id:
text += f"📎 File ID: `{msg.file_id}`\n\n"
if msg.is_deleted:
text += f"🗑 *Удалено:* Да\n"
if msg.deleted_at:
text += f" Дата: {msg.deleted_at.strftime('%d.%m.%Y %H:%M')}\n"
buttons = []
# Кнопка удаления (если еще не удалено)
if not msg.is_deleted:
buttons.append([InlineKeyboardButton(
text="🗑 Удалить сообщение",
callback_data=f"admin_message_delete_{message_id}"
)])
# Кнопка для просмотра всех сообщений пользователя
buttons.append([InlineKeyboardButton(
text="📋 Все сообщения пользователя",
callback_data=f"admin_messages_user_{sender.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 К списку", callback_data="admin_messages_recent")])
# Если сообщение содержит медиа, попробуем его показать
if msg.file_id and msg.message_type in ['photo', 'video', 'document', 'animation']:
try:
if msg.message_type == 'photo':
await callback.message.answer_photo(
photo=msg.file_id,
caption=text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
await callback.message.delete()
await callback.answer()
return
elif msg.message_type == 'video':
await callback.message.answer_video(
video=msg.file_id,
caption=text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
await callback.message.delete()
await callback.answer()
return
except Exception as e:
logger.error(f"Ошибка при отправке медиа: {e}")
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
@admin_router.callback_query(F.data.startswith("admin_message_delete_"))
async def delete_message(callback: CallbackQuery):
"""Удалить сообщение пользователя"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
message_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
msg = await ChatMessageService.get_message(session, message_id)
if not msg:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
# Получаем админа
admin = await UserService.get_or_create_user(
session,
callback.from_user.id,
callback.from_user.username,
callback.from_user.first_name,
callback.from_user.last_name
)
# Помечаем сообщение как удаленное
success = await ChatMessageService.mark_as_deleted(
session,
message_id,
admin.id
)
if success:
# Пытаемся удалить сообщение из чата пользователя
try:
if msg.forwarded_message_ids:
# Удаляем пересланные копии у всех пользователей
for user_tg_id, tg_msg_id in msg.forwarded_message_ids.items():
try:
await callback.bot.delete_message(
chat_id=int(user_tg_id),
message_id=tg_msg_id
)
except Exception as e:
logger.warning(f"Не удалось удалить сообщение {tg_msg_id} у пользователя {user_tg_id}: {e}")
# Удаляем оригинальное сообщение у отправителя
try:
await callback.bot.delete_message(
chat_id=msg.sender.telegram_id,
message_id=msg.telegram_message_id
)
except Exception as e:
logger.warning(f"Не удалось удалить оригинальное сообщение: {e}")
await callback.answer("✅ Сообщение удалено!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка при удалении сообщений: {e}")
await callback.answer("⚠️ Помечено как удаленное", show_alert=True)
else:
await callback.answer("❌ Ошибка при удалении", show_alert=True)
# Возвращаемся к списку
await show_recent_messages(callback, 0)
@admin_router.callback_query(F.data.startswith("admin_messages_user_"))
async def show_user_messages(callback: CallbackQuery):
"""Показать все сообщения конкретного пользователя"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
user_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
user = await UserService.get_user_by_id(session, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
messages = await ChatMessageService.get_user_messages(
session,
user_id,
limit=20,
include_deleted=True
)
username = f"@{user.username}" if user.username else f"ID: {user.telegram_id}"
text = f"💬 *Сообщения {username}*\n\n"
if not messages:
text += "Нет сообщений"
buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages_recent")]]
else:
# Кнопки для просмотра отдельных сообщений
buttons = []
for msg in messages[:15]:
status = "🗑" if msg.is_deleted else ""
msg_preview = ""
if msg.text:
msg_preview = msg.text[:25] + "..." if len(msg.text) > 25 else msg.text
else:
msg_preview = msg.message_type
buttons.append([InlineKeyboardButton(
text=f"{status} {msg_preview} ({msg.created_at.strftime('%d.%m %H:%M')})",
callback_data=f"admin_message_view_{msg.id}"
)])
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_messages_recent")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="Markdown"
)
# Экспорт роутера
__all__ = ['admin_router']

View File

@@ -109,6 +109,73 @@ async def handle_text_message(message: Message):
logger = logging.getLogger(__name__)
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
if message.reply_to_message and is_admin(message.from_user.id):
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
async with async_session_maker() as session:
# Ищем сообщение в БД по telegram_message_id
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
session,
telegram_message_id=message.reply_to_message.message_id
)
if msg_to_delete:
# Получаем админа
admin = await UserService.get_user_by_telegram_id(
session,
message.from_user.id
)
# Помечаем как удаленное
success = await ChatMessageService.mark_as_deleted(
session,
msg_to_delete.id,
admin.id if admin else None
)
if success:
# Удаляем у всех получателей
deleted_count = 0
if msg_to_delete.forwarded_message_ids:
for user_tg_id, tg_msg_id in msg_to_delete.forwarded_message_ids.items():
try:
await message.bot.delete_message(
chat_id=int(user_tg_id),
message_id=tg_msg_id
)
deleted_count += 1
except Exception as e:
logger.warning(f"Не удалось удалить {tg_msg_id} у {user_tg_id}: {e}")
# Удаляем оригинал у отправителя
try:
await message.bot.delete_message(
chat_id=msg_to_delete.sender.telegram_id,
message_id=msg_to_delete.telegram_message_id
)
deleted_count += 1
except Exception as e:
logger.warning(f"Не удалось удалить оригинал: {e}")
# Удаляем команду админа
try:
await message.delete()
except:
pass
# Отправляем уведомление (самоудаляющееся)
notification = await message.answer(f"✅ Сообщение удалено у {deleted_count} получателей")
await asyncio.sleep(3)
try:
await notification.delete()
except:
pass
return
else:
await message.answer("❌ Сообщение не найдено в БД")
return
# Проверяем является ли это командой
if message.text and message.text.startswith('/'):
# Список команд, которые НЕ нужно пересылать
@@ -123,21 +190,20 @@ async def handle_text_message(message: Message):
# Извлекаем команду (первое слово)
command = message.text.split()[0] if message.text else ''
# Если это пользовательская команда - пропускаем, она будет обработана другими обработчиками
if command in user_commands:
return
# Если это админская команда
if command in admin_commands:
# Проверяем права админа
if not is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды")
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
if is_admin(message.from_user.id):
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
if command in admin_commands:
return
# Если админ - команда будет обработана другими обработчиками, пропускаем пересылку
# Если это пользовательская команда от админа - тоже пропускаем
if command in user_commands:
return
# Любая другая команда от админа - тоже не пересылаем
return
# Если неизвестная команда - тоже не пересылаем
return
# ИЗМЕНЕНИЕ: Если команда от обычного пользователя - ПЕРЕСЫЛАЕМ админу
# Чтобы админ видел, что пользователь отправил /start или другую команду
# НЕ делаем return, продолжаем выполнение для пересылки
async with async_session_maker() as session:
# Проверяем права на отправку
@@ -163,7 +229,8 @@ async def handle_text_message(message: Message):
# Обрабатываем в зависимости от режима
if settings.mode == 'broadcast':
# Режим рассылки с планировщиком
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Сохраняем сообщение в историю
await ChatMessageService.save_message(
@@ -231,7 +298,12 @@ async def handle_photo_message(message: Message):
photo = message.photo[-1]
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# Отправляем только админам
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
admin_only=True
)
await ChatMessageService.save_message(
session,
@@ -245,7 +317,7 @@ async def handle_photo_message(message: Message):
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Фото разослано: {success} получателей")
await message.answer(f"✅ Фото отправлено админам: {success}")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -285,7 +357,8 @@ async def handle_video_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -339,7 +412,8 @@ async def handle_document_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -393,7 +467,8 @@ async def handle_animation_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -447,7 +522,8 @@ async def handle_sticker_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
await ChatMessageService.save_message(
session,
@@ -480,51 +556,19 @@ async def handle_sticker_message(message: Message):
@router.message(F.voice)
async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений"""
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
await message.answer(f"{reason}")
return
settings = await ChatSettingsService.get_or_create_settings(session)
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
user_id=user.id,
telegram_message_id=message.message_id,
message_type='voice',
file_id=message.voice.file_id,
forwarded_ids=forwarded_ids
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
if success:
await ChatMessageService.save_message(
session,
user_id=user.id,
telegram_message_id=message.message_id,
message_type='voice',
file_id=message.voice.file_id,
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Голосовое сообщение переслано в канал")
"""Обработчик голосовых сообщений - ЗАБЛОКИРОВАНО"""
await message.answer(
"🚫 Голосовые сообщения запрещены.\n\n"
"Пожалуйста, используйте текстовые сообщения или изображения."
)
return
@router.message(F.audio)
async def handle_audio_message(message: Message):
"""Обработчик аудиофайлов (музыка, аудиозаписи) - ЗАБЛОКИРОВАНО"""
await message.answer(
"🚫 Аудиофайлы запрещены.\n\n"
"Пожалуйста, используйте текстовые сообщения или изображения."
)
return

View File

@@ -304,3 +304,97 @@ async def redraw_lottery(message: Message):
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data.startswith("confirm_win_"))
async def confirm_winner_callback(callback_query):
"""Обработка подтверждения выигрыша победителем"""
from aiogram.types import CallbackQuery
winner_id = int(callback_query.data.split("_")[-1])
async with async_session_maker() as session:
# Получаем информацию о победителе
winner_result = await session.execute(
select(Winner).where(Winner.id == winner_id)
)
winner = winner_result.scalar_one_or_none()
if not winner:
await callback_query.answer("❌ Победитель не найден", show_alert=True)
return
if winner.is_claimed:
await callback_query.answer(
"✅ Этот выигрыш уже подтвержден!",
show_alert=True
)
return
# Проверяем, что пользователь является владельцем счёта
if winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if not owner or owner.telegram_id != callback_query.from_user.id:
await callback_query.answer(
"❌ Вы не являетесь владельцем этого счёта",
show_alert=True
)
return
# Проверяем срок действия (24 часа с момента создания winner)
if winner.created_at:
time_since_creation = datetime.now(timezone.utc) - winner.created_at
if time_since_creation > timedelta(hours=24):
await callback_query.answer(
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
show_alert=True
)
return
# Подтверждаем выигрыш
winner.is_claimed = True
winner.claimed_at = datetime.now(timezone.utc)
await session.commit()
# Получаем данные о розыгрыше
lottery = await LotteryService.get_lottery(session, winner.lottery_id)
# Отправляем подтверждение пользователю
confirmation_text = (
f"✅ **Выигрыш подтвержден!**\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 Счет: {winner.account_number}\n\n"
f"📞 С вами свяжется администратор для вручения приза.\n"
f"Спасибо за участие!"
)
await callback_query.message.edit_text(
confirmation_text,
parse_mode="Markdown"
)
# Уведомляем админов
for admin_id in ADMIN_IDS:
try:
admin_text = (
f"✅ **Подтверждение выигрыша**\n\n"
f"👤 Пользователь: {callback_query.from_user.full_name} "
f"(@{callback_query.from_user.username or 'нет username'})\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 Счет: {winner.account_number}"
)
from aiogram import Bot
from src.core.config import BOT_TOKEN
bot = Bot(token=BOT_TOKEN)
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)