diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md new file mode 100644 index 0000000..43f2148 --- /dev/null +++ b/MIGRATION_SUMMARY.md @@ -0,0 +1,93 @@ +# 📋 Итоговый Отчет: Миграция 006 - Исправление Схемы БД + +## ✅ Выполненные задачи + +### 1. **Создана миграция 006_fix_missing_columns.py** +- ✅ Автоматическое добавление отсутствующих столбцов +- ✅ Идемпотентность (безопасно для повторного выполнения) +- ✅ Поддержка отката (downgrade функция) +- ✅ Проверка существования столбцов перед добавлением + +### 2. **Исправленные столбцы:** + +**Таблица `participations`:** +- ✅ `account_id` (INTEGER) + FK на `accounts(id)` + +**Таблица `winners`:** +- ✅ `is_notified` (BOOLEAN DEFAULT FALSE) +- ✅ `is_claimed` (BOOLEAN DEFAULT FALSE) +- ✅ `claimed_at` (TIMESTAMP WITH TIME ZONE) + +### 3. **Применение миграции:** +```bash +# До миграции: 005 (add_chat_system) +alembic upgrade head +# После миграции: 006 (fix_missing_columns) ← HEAD +``` + +### 4. **Проверка результата:** +```sql +-- participations: account_id добавлен ✅ +-- winners: is_notified, is_claimed, claimed_at добавлены ✅ +``` + +### 5. **Документация:** +- ✅ Создан `MIGRATION_006_REPORT.md` с подробным описанием +- ✅ Обновлен `README.md` с информацией о миграциях +- ✅ Добавлен список всех миграций проекта + +## 🚀 Результат + +### ✅ Преимущества: +1. **Автоматизация:** Все изменения БД теперь применяются через `alembic upgrade head` +2. **Безопасность:** Миграция проверяет существование столбцов +3. **Откат:** Возможность отката изменений при необходимости +4. **Документирование:** Все изменения задокументированы +5. **Production-ready:** Готово к развертыванию на production + +### ✅ Проверка работоспособности: +```bash +# Бот запускается без ошибок ✅ +python main.py +# 2025-11-17 05:37:26,848 - __main__ - INFO - Запуск бота... +# 2025-11-17 05:37:26,848 - __main__ - INFO - Бот запущен +# 2025-11-17 05:37:27,767 - aiogram.dispatcher - INFO - Run polling +``` + +## 📦 Коммиты в Git: + +### 1. **Основной рефакторинг** (commit: `4a74171`) +``` +feat: Полный рефакторинг с модульной архитектурой +- Исправлены критические ошибки callback обработки +- Реализована модульная архитектура с применением SOLID принципов +- Добавлена система dependency injection +``` + +### 2. **Миграция БД** (commit: `0623de5`) +``` +feat: Добавлена миграция 006 для исправления схемы БД +- Создана миграция 006_fix_missing_columns.py +- Автоматически добавляет отсутствующие столбцы +- Миграция идемпотентна +``` + +--- + +## 🎯 Заключение + +**Все изменения в базе данных вынесены в миграцию 006.** + +### Для разработчиков: +При развертывании на любом сервере достаточно выполнить: +```bash +alembic upgrade head +``` + +### Для администраторов: +- Схема БД автоматически приводится к актуальному состоянию +- Нет необходимости в ручных SQL скриптах +- Возможность отката при проблемах +- Полная прослеживаемость изменений + +**🎉 Проект полностью готов к production развертыванию!** \ No newline at end of file diff --git a/main.py b/main.py index 4b141e7..a135dbf 100644 --- a/main.py +++ b/main.py @@ -129,12 +129,11 @@ async def main(): dp.include_router(redraw_router) # Повторные розыгрыши dp.include_router(p2p_chat_router) # P2P чат между пользователями - # 3. Account router ПЕРЕД chat_router (обнаружение счетов для админов) - dp.include_router(account_router) # Пользовательские счета + обнаружение для админов + # 3. Chat router для broadcast (обрабатывает обычные сообщения) + dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - РАНЬШЕ account_router - # 4. Chat router для broadcast (ловит все необработанные сообщения) - # chat_router пропускает сообщения со счетами от админов - dp.include_router(chat_router) # Пользовательский чат (broadcast всем) + # 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов) + dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router # Запускаем polling try: diff --git a/migrations/versions/20260208_2121_25_beb47ddbfc33_.py b/migrations/versions/20260208_2121_25_beb47ddbfc33_.py new file mode 100644 index 0000000..2017e93 --- /dev/null +++ b/migrations/versions/20260208_2121_25_beb47ddbfc33_.py @@ -0,0 +1,200 @@ +""" + +Revision ID: beb47ddbfc33 +Revises: 008 +Create Date: 2026-02-08 21:21:25.254747 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'beb47ddbfc33' +down_revision = '008' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('accounts', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('accounts', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('true')) + op.drop_index('ix_accounts_owner_id', table_name='accounts') + op.drop_constraint('accounts_owner_id_fkey', 'accounts', type_='foreignkey') + op.create_foreign_key(None, 'accounts', 'users', ['owner_id'], ['id']) + op.alter_column('banned_users', 'banned_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('banned_users', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('true')) + op.drop_constraint('banned_users_user_id_fkey', 'banned_users', type_='foreignkey') + op.drop_constraint('banned_users_banned_by_fkey', 'banned_users', type_='foreignkey') + op.create_foreign_key(None, 'banned_users', 'users', ['banned_by'], ['id']) + op.create_foreign_key(None, 'banned_users', 'users', ['user_id'], ['id']) + op.alter_column('chat_messages', 'forwarded_message_ids', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.JSON(), + existing_nullable=True) + op.alter_column('chat_messages', 'is_deleted', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('chat_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_index('ix_chat_messages_user_id', table_name='chat_messages') + op.drop_constraint('chat_messages_user_id_fkey', 'chat_messages', type_='foreignkey') + op.drop_constraint('chat_messages_deleted_by_fkey', 'chat_messages', type_='foreignkey') + op.create_foreign_key(None, 'chat_messages', 'users', ['user_id'], ['id']) + op.create_foreign_key(None, 'chat_messages', 'users', ['deleted_by'], ['id']) + op.alter_column('chat_settings', 'global_ban', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('chat_settings', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('chat_settings', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('p2p_messages', 'is_read', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('p2p_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.drop_constraint('fk_participations_account_id', 'participations', type_='foreignkey') + op.create_foreign_key(None, 'participations', 'accounts', ['account_id'], ['id']) + op.alter_column('users', 'is_registered', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.drop_index('ix_users_verification_code', table_name='users') + op.create_unique_constraint(None, 'users', ['verification_code']) + op.alter_column('winner_verifications', 'is_verified', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('winner_verifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.drop_index('ix_winner_verifications_token', table_name='winner_verifications') + op.drop_index('ix_winner_verifications_winner_id', table_name='winner_verifications') + op.create_unique_constraint(None, 'winner_verifications', ['verification_token']) + op.create_unique_constraint(None, 'winner_verifications', ['winner_id']) + op.drop_constraint('winner_verifications_winner_id_fkey', 'winner_verifications', type_='foreignkey') + op.create_foreign_key(None, 'winner_verifications', 'winners', ['winner_id'], ['id']) + op.alter_column('winners', 'is_notified', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('winners', 'is_claimed', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('winners', 'is_claimed', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('winners', 'is_notified', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.drop_constraint(None, 'winner_verifications', type_='foreignkey') + op.create_foreign_key('winner_verifications_winner_id_fkey', 'winner_verifications', 'winners', ['winner_id'], ['id'], ondelete='CASCADE') + op.drop_constraint(None, 'winner_verifications', type_='unique') + op.drop_constraint(None, 'winner_verifications', type_='unique') + op.create_index('ix_winner_verifications_winner_id', 'winner_verifications', ['winner_id'], unique=True) + op.create_index('ix_winner_verifications_token', 'winner_verifications', ['verification_token'], unique=True) + op.alter_column('winner_verifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('winner_verifications', 'is_verified', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.drop_constraint(None, 'users', type_='unique') + op.create_index('ix_users_verification_code', 'users', ['verification_code'], unique=True) + op.alter_column('users', 'is_registered', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.drop_constraint(None, 'participations', type_='foreignkey') + op.create_foreign_key('fk_participations_account_id', 'participations', 'accounts', ['account_id'], ['id'], ondelete='SET NULL') + op.alter_column('p2p_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('p2p_messages', 'is_read', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('chat_settings', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('chat_settings', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('chat_settings', 'global_ban', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.drop_constraint(None, 'chat_messages', type_='foreignkey') + op.drop_constraint(None, 'chat_messages', type_='foreignkey') + op.create_foreign_key('chat_messages_deleted_by_fkey', 'chat_messages', 'users', ['deleted_by'], ['id'], ondelete='SET NULL') + op.create_foreign_key('chat_messages_user_id_fkey', 'chat_messages', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.create_index('ix_chat_messages_user_id', 'chat_messages', ['user_id'], unique=False) + op.alter_column('chat_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('chat_messages', 'is_deleted', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('chat_messages', 'forwarded_message_ids', + existing_type=sa.JSON(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True) + op.drop_constraint(None, 'banned_users', type_='foreignkey') + op.drop_constraint(None, 'banned_users', type_='foreignkey') + op.create_foreign_key('banned_users_banned_by_fkey', 'banned_users', 'users', ['banned_by'], ['id'], ondelete='SET NULL') + op.create_foreign_key('banned_users_user_id_fkey', 'banned_users', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.alter_column('banned_users', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('true')) + op.alter_column('banned_users', 'banned_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_constraint(None, 'accounts', type_='foreignkey') + op.create_foreign_key('accounts_owner_id_fkey', 'accounts', 'users', ['owner_id'], ['id'], ondelete='CASCADE') + op.create_index('ix_accounts_owner_id', 'accounts', ['owner_id'], unique=False) + op.alter_column('accounts', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('true')) + op.alter_column('accounts', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + # ### end Alembic commands ### \ No newline at end of file diff --git a/src/components/ui.py b/src/components/ui.py index 784a5d3..e4f715d 100644 --- a/src/components/ui.py +++ b/src/components/ui.py @@ -11,7 +11,8 @@ class KeyboardBuilderImpl(IKeyboardBuilder): def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False): """Получить главную клавиатуру""" buttons = [ - [InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")] + [InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")], + [InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")] ] # Показываем кнопку регистрации только незарегистрированным пользователям (не админам) diff --git a/src/handlers/admin_chat_handlers.py b/src/handlers/admin_chat_handlers.py index 35cac33..83baed7 100644 --- a/src/handlers/admin_chat_handlers.py +++ b/src/handlers/admin_chat_handlers.py @@ -163,7 +163,13 @@ async def cmd_ban(message: Message): return # Получаем админа - admin = await UserService.get_user_by_telegram_id(session, message.from_user.id) + admin = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) # Баним ban = await BanService.ban_user( @@ -271,7 +277,13 @@ async def cmd_delete_message(message: Message): async with async_session_maker() as session: # Получаем админа - admin = await UserService.get_user_by_telegram_id(session, message.from_user.id) + admin = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) # Находим сообщение в базе по telegram_message_id from sqlalchemy import select diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index fb6058b..60a4558 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -414,7 +414,13 @@ async def confirm_create_lottery(callback: CallbackQuery, state: FSMContext): data = await state.get_data() async with async_session_maker() as session: - user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + user = await UserService.get_or_create_user( + session, + callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name + ) lottery = await LotteryService.create_lottery( session, diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py index 74997a2..bd06b72 100644 --- a/src/handlers/chat_handlers.py +++ b/src/handlers/chat_handlers.py @@ -1,6 +1,9 @@ """Обработчики пользовательских сообщений в чате""" from aiogram import Router, F -from aiogram.types import Message +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.filters import StateFilter, Command from sqlalchemy.ext.asyncio import AsyncSession import asyncio from typing import List, Dict, Optional, Set @@ -19,6 +22,11 @@ from src.core.config import ADMIN_IDS from src.utils.account_utils import parse_accounts_from_message +class ChatStates(StatesGroup): + """Состояния для работы в чате""" + in_chat = State() # Пользователь находится в режиме чата + + def is_admin(user_id: int) -> bool: """Проверка является ли пользователь админом""" return user_id in ADMIN_IDS @@ -34,6 +42,69 @@ def _contains_account_numbers(text: str) -> bool: router = Router(name='chat_router') + +@router.message(Command("chat")) +async def enter_chat_command(message: Message, state: FSMContext): + """Войти в режим чата через команду /chat""" + await enter_chat(message, state) + + +@router.callback_query(F.data == "enter_chat") +async def enter_chat_callback(callback: CallbackQuery, state: FSMContext): + """Войти в режим чата через кнопку""" + await callback.answer() + await enter_chat(callback.message, state) + + +async def enter_chat(message: Message, state: FSMContext): + """Общая функция входа в чат""" + await state.set_state(ChatStates.in_chat) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🚪 Выйти из чата", callback_data="exit_chat")], + [InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")] + ]) + + await message.answer( + "💬 Вы вошли в режим чата\n\n" + "Теперь все ваши сообщения будут рассылаться участникам.\n" + "Вы можете отправлять текст, фото, видео, документы и стикеры.\n\n" + "Для выхода нажмите кнопку ниже или отправьте /exit", + reply_markup=keyboard, + parse_mode="HTML" + ) + + +@router.message(Command("exit"), StateFilter(ChatStates.in_chat)) +async def exit_chat_command(message: Message, state: FSMContext): + """Выйти из режима чата через команду /exit""" + await exit_chat(message, state) + + +@router.callback_query(F.data == "exit_chat", StateFilter(ChatStates.in_chat)) +async def exit_chat_callback(callback: CallbackQuery, state: FSMContext): + """Выйти из режима чата через кнопку""" + await callback.answer() + await exit_chat(callback.message, state) + + +async def exit_chat(message: Message, state: FSMContext): + """Общая функция выхода из чата""" + await state.clear() + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")], + [InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")] + ]) + + await message.answer( + "✅ Вы вышли из режима чата\n\n" + "Ваши сообщения больше не будут рассылаться.", + reply_markup=keyboard, + parse_mode="HTML" + ) + + # Настройки для планировщика рассылки BATCH_SIZE = 20 # Количество сообщений в пакете BATCH_DELAY = 1.0 # Задержка между пакетами в секундах @@ -224,8 +295,8 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O return False, None -@router.message(F.text) -async def handle_text_message(message: Message): +@router.message(F.text, StateFilter(ChatStates.in_chat)) +async def handle_text_message(message: Message, state: FSMContext): """Обработчик текстовых сообщений""" import logging logger = logging.getLogger(__name__) @@ -238,11 +309,13 @@ async def handle_text_message(message: Message): logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}") # ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем - # Это сообщение будет обработано account_router для добавления в розыгрыш + # Пропускаем для account_router (который идет после chat_router) if is_admin(message.from_user.id) and message.text and not message.text.startswith('/'): if _contains_account_numbers(message.text): - logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем рассылку (обработает account_router)") - return # Пропускаем - обработает account_router + logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает") + # Не делаем return, выбрасываем исключение для пропуска в следующий обработчик + from aiogram.handlers import SkipHandler + raise SkipHandler() # БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-" if message.reply_to_message and is_admin(message.from_user.id): @@ -256,9 +329,12 @@ async def handle_text_message(message: Message): if msg_to_delete: # Получаем админа - admin = await UserService.get_user_by_telegram_id( + admin = await UserService.get_or_create_user( session, - message.from_user.id + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name ) # Помечаем как удаленное @@ -355,11 +431,14 @@ async def handle_text_message(message: Message): # Получаем настройки чата settings = await ChatSettingsService.get_or_create_settings(session) - # Получаем пользователя - user = await UserService.get_user_by_telegram_id(session, message.from_user.id) - if not user: - await message.answer("❌ Пользователь не найден") - return + # Получаем или создаем пользователя + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) # Обрабатываем в зависимости от режима if settings.mode == 'broadcast': @@ -421,8 +500,8 @@ async def handle_text_message(message: Message): await message.answer("❌ Не удалось переслать сообщение") -@router.message(F.photo) -async def handle_photo_message(message: Message): +@router.message(F.photo, StateFilter(ChatStates.in_chat)) +async def handle_photo_message(message: Message, state: FSMContext): """Обработчик фото""" # Защита от дубликатов if _is_message_processed(message.message_id): @@ -440,10 +519,13 @@ async def handle_photo_message(message: Message): 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 + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) # Получаем file_id самого большого фото photo = message.photo[-1] @@ -495,8 +577,8 @@ async def handle_photo_message(message: Message): await message.answer("✅ Фото переслано в канал") -@router.message(F.video) -async def handle_video_message(message: Message): +@router.message(F.video, StateFilter(ChatStates.in_chat)) +async def handle_video_message(message: Message, state: FSMContext): """Обработчик видео""" # Защита от дубликатов if _is_message_processed(message.message_id): @@ -514,10 +596,13 @@ async def handle_video_message(message: Message): 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 + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) if settings.mode == 'broadcast': # Формируем информацию об отправителе для админов (если это не админ) @@ -565,8 +650,8 @@ async def handle_video_message(message: Message): await message.answer("✅ Видео переслано в канал") -@router.message(F.document) -async def handle_document_message(message: Message): +@router.message(F.document, StateFilter(ChatStates.in_chat)) +async def handle_document_message(message: Message, state: FSMContext): """Обработчик документов""" # Защита от дубликатов if _is_message_processed(message.message_id): @@ -584,10 +669,13 @@ async def handle_document_message(message: Message): 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 + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) if settings.mode == 'broadcast': # Формируем информацию об отправителе для админов (если это не админ) @@ -635,8 +723,8 @@ async def handle_document_message(message: Message): await message.answer("✅ Документ переслан в канал") -@router.message(F.animation) -async def handle_animation_message(message: Message): +@router.message(F.animation, StateFilter(ChatStates.in_chat)) +async def handle_animation_message(message: Message, state: FSMContext): """Обработчик GIF анимаций""" # Защита от дубликатов if _is_message_processed(message.message_id): @@ -654,10 +742,13 @@ async def handle_animation_message(message: Message): 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 + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) if settings.mode == 'broadcast': # Формируем информацию об отправителе для админов (если это не админ) @@ -705,8 +796,8 @@ async def handle_animation_message(message: Message): await message.answer("✅ Анимация переслана в канал") -@router.message(F.sticker) -async def handle_sticker_message(message: Message): +@router.message(F.sticker, StateFilter(ChatStates.in_chat)) +async def handle_sticker_message(message: Message, state: FSMContext): """Обработчик стикеров""" # Защита от дубликатов if _is_message_processed(message.message_id): @@ -724,10 +815,13 @@ async def handle_sticker_message(message: Message): 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 + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) if settings.mode == 'broadcast': # Формируем информацию об отправителе для админов (если это не админ) diff --git a/src/handlers/p2p_chat.py b/src/handlers/p2p_chat.py index c00c343..c337c61 100644 --- a/src/handlers/p2p_chat.py +++ b/src/handlers/p2p_chat.py @@ -38,7 +38,13 @@ async def show_chat_menu(message: Message, state: FSMContext): await state.clear() async with async_session_maker() as session: - user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + user = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) if not user: await message.answer("❌ Вы не зарегистрированы. Используйте /start") @@ -134,7 +140,13 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text("❌ Пользователь не найден") return - sender = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + sender = await UserService.get_or_create_user( + session, + callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name + ) # Получаем последние 10 сообщений из диалога messages = await P2PMessageService.get_conversation( @@ -182,7 +194,13 @@ async def show_conversations(callback: CallbackQuery): await callback.answer() async with async_session_maker() as session: - user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + sender = await UserService.get_or_create_user( + session, + callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name + ) conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10) @@ -274,7 +292,13 @@ async def handle_p2p_message(message: Message, state: FSMContext): return async with async_session_maker() as session: - sender = await UserService.get_user_by_telegram_id(session, message.from_user.id) + sender = await UserService.get_or_create_user( + session, + message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) sender_name = f"@{sender.username}" if sender.username else sender.first_name # Определяем тип сообщения