This commit is contained in:
93
MIGRATION_SUMMARY.md
Normal file
93
MIGRATION_SUMMARY.md
Normal file
@@ -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 развертыванию!**
|
||||||
9
main.py
9
main.py
@@ -129,12 +129,11 @@ async def main():
|
|||||||
dp.include_router(redraw_router) # Повторные розыгрыши
|
dp.include_router(redraw_router) # Повторные розыгрыши
|
||||||
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
||||||
|
|
||||||
# 3. Account router ПЕРЕД chat_router (обнаружение счетов для админов)
|
# 3. Chat router для broadcast (обрабатывает обычные сообщения)
|
||||||
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
|
dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - РАНЬШЕ account_router
|
||||||
|
|
||||||
# 4. Chat router для broadcast (ловит все необработанные сообщения)
|
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||||
# chat_router пропускает сообщения со счетами от админов
|
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||||
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
|
|
||||||
|
|
||||||
# Запускаем polling
|
# Запускаем polling
|
||||||
try:
|
try:
|
||||||
|
|||||||
200
migrations/versions/20260208_2121_25_beb47ddbfc33_.py
Normal file
200
migrations/versions/20260208_2121_25_beb47ddbfc33_.py
Normal file
@@ -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 ###
|
||||||
@@ -11,7 +11,8 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
|||||||
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
||||||
"""Получить главную клавиатуру"""
|
"""Получить главную клавиатуру"""
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")]
|
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
|
||||||
|
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
||||||
|
|||||||
@@ -163,7 +163,13 @@ async def cmd_ban(message: Message):
|
|||||||
return
|
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(
|
ban = await BanService.ban_user(
|
||||||
@@ -271,7 +277,13 @@ async def cmd_delete_message(message: Message):
|
|||||||
|
|
||||||
async with async_session_maker() as session:
|
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
|
# Находим сообщение в базе по telegram_message_id
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -414,7 +414,13 @@ async def confirm_create_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
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(
|
lottery = await LotteryService.create_lottery(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Обработчики пользовательских сообщений в чате"""
|
"""Обработчики пользовательских сообщений в чате"""
|
||||||
from aiogram import Router, F
|
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
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Optional, Set
|
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
|
from src.utils.account_utils import parse_accounts_from_message
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStates(StatesGroup):
|
||||||
|
"""Состояния для работы в чате"""
|
||||||
|
in_chat = State() # Пользователь находится в режиме чата
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user_id: int) -> bool:
|
def is_admin(user_id: int) -> bool:
|
||||||
"""Проверка является ли пользователь админом"""
|
"""Проверка является ли пользователь админом"""
|
||||||
return user_id in ADMIN_IDS
|
return user_id in ADMIN_IDS
|
||||||
@@ -34,6 +42,69 @@ def _contains_account_numbers(text: str) -> bool:
|
|||||||
|
|
||||||
router = Router(name='chat_router')
|
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(
|
||||||
|
"💬 <b>Вы вошли в режим чата</b>\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(
|
||||||
|
"✅ <b>Вы вышли из режима чата</b>\n\n"
|
||||||
|
"Ваши сообщения больше не будут рассылаться.",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Настройки для планировщика рассылки
|
# Настройки для планировщика рассылки
|
||||||
BATCH_SIZE = 20 # Количество сообщений в пакете
|
BATCH_SIZE = 20 # Количество сообщений в пакете
|
||||||
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
|
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
|
||||||
@@ -224,8 +295,8 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text)
|
@router.message(F.text, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_text_message(message: Message):
|
async def handle_text_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик текстовых сообщений"""
|
"""Обработчик текстовых сообщений"""
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
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'}")
|
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 is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
||||||
if _contains_account_numbers(message.text):
|
if _contains_account_numbers(message.text):
|
||||||
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем рассылку (обработает account_router)")
|
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает")
|
||||||
return # Пропускаем - обработает account_router
|
# Не делаем return, выбрасываем исключение для пропуска в следующий обработчик
|
||||||
|
from aiogram.handlers import SkipHandler
|
||||||
|
raise SkipHandler()
|
||||||
|
|
||||||
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
||||||
if message.reply_to_message and is_admin(message.from_user.id):
|
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:
|
if msg_to_delete:
|
||||||
# Получаем админа
|
# Получаем админа
|
||||||
admin = await UserService.get_user_by_telegram_id(
|
admin = await UserService.get_or_create_user(
|
||||||
session,
|
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)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
|
|
||||||
# Получаем пользователя
|
# Получаем или создаем пользователя
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
if not user:
|
session,
|
||||||
await message.answer("❌ Пользователь не найден")
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
# Обрабатываем в зависимости от режима
|
# Обрабатываем в зависимости от режима
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
@@ -421,8 +500,8 @@ async def handle_text_message(message: Message):
|
|||||||
await message.answer("❌ Не удалось переслать сообщение")
|
await message.answer("❌ Не удалось переслать сообщение")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.photo)
|
@router.message(F.photo, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_photo_message(message: Message):
|
async def handle_photo_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик фото"""
|
"""Обработчик фото"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
@@ -440,10 +519,13 @@ async def handle_photo_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
|
session,
|
||||||
if not user:
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
# Получаем file_id самого большого фото
|
# Получаем file_id самого большого фото
|
||||||
photo = message.photo[-1]
|
photo = message.photo[-1]
|
||||||
@@ -495,8 +577,8 @@ async def handle_photo_message(message: Message):
|
|||||||
await message.answer("✅ Фото переслано в канал")
|
await message.answer("✅ Фото переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.video)
|
@router.message(F.video, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_video_message(message: Message):
|
async def handle_video_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик видео"""
|
"""Обработчик видео"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
@@ -514,10 +596,13 @@ async def handle_video_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
|
session,
|
||||||
if not user:
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Формируем информацию об отправителе для админов (если это не админ)
|
||||||
@@ -565,8 +650,8 @@ async def handle_video_message(message: Message):
|
|||||||
await message.answer("✅ Видео переслано в канал")
|
await message.answer("✅ Видео переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.document)
|
@router.message(F.document, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_document_message(message: Message):
|
async def handle_document_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик документов"""
|
"""Обработчик документов"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
@@ -584,10 +669,13 @@ async def handle_document_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
|
session,
|
||||||
if not user:
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Формируем информацию об отправителе для админов (если это не админ)
|
||||||
@@ -635,8 +723,8 @@ async def handle_document_message(message: Message):
|
|||||||
await message.answer("✅ Документ переслан в канал")
|
await message.answer("✅ Документ переслан в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.animation)
|
@router.message(F.animation, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_animation_message(message: Message):
|
async def handle_animation_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик GIF анимаций"""
|
"""Обработчик GIF анимаций"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
@@ -654,10 +742,13 @@ async def handle_animation_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
|
session,
|
||||||
if not user:
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Формируем информацию об отправителе для админов (если это не админ)
|
||||||
@@ -705,8 +796,8 @@ async def handle_animation_message(message: Message):
|
|||||||
await message.answer("✅ Анимация переслана в канал")
|
await message.answer("✅ Анимация переслана в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.sticker)
|
@router.message(F.sticker, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_sticker_message(message: Message):
|
async def handle_sticker_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик стикеров"""
|
"""Обработчик стикеров"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
@@ -724,10 +815,13 @@ async def handle_sticker_message(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_or_create_user(
|
||||||
|
session,
|
||||||
if not user:
|
message.from_user.id,
|
||||||
return
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Формируем информацию об отправителе для админов (если это не админ)
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ async def show_chat_menu(message: Message, state: FSMContext):
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
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:
|
if not user:
|
||||||
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
|
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
|
||||||
@@ -134,7 +140,13 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.message.edit_text("❌ Пользователь не найден")
|
await callback.message.edit_text("❌ Пользователь не найден")
|
||||||
return
|
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 сообщений из диалога
|
# Получаем последние 10 сообщений из диалога
|
||||||
messages = await P2PMessageService.get_conversation(
|
messages = await P2PMessageService.get_conversation(
|
||||||
@@ -182,7 +194,13 @@ async def show_conversations(callback: CallbackQuery):
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
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)
|
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
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
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
|
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||||
|
|
||||||
# Определяем тип сообщения
|
# Определяем тип сообщения
|
||||||
|
|||||||
Reference in New Issue
Block a user