From b6c27b7b70bd3eb099f67ef2de71cc3dc1d69f04 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Sun, 16 Nov 2025 14:25:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=20=D1=87=D0=B0=D1=82=D0=B0=20=D1=81=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализована полнофункциональная система чата с двумя режимами работы: ## Режимы работы: - Broadcast: рассылка сообщений всем пользователям - Forward: пересылка сообщений в указанную группу/канал ## Функционал: - Поддержка всех типов сообщений: text, photo, video, document, animation, sticker, voice - Система банов: личные баны пользователей и глобальный бан чата - Модерация: удаление сообщений с отслеживанием в БД - История сообщений с сохранением ID пересланных сообщений ## Структура БД (миграция 005): - chat_settings: настройки чата (режим, ID канала, глобальный бан) - banned_users: история банов с причинами и информацией о модераторе - chat_messages: история сообщений с типами, файлами и картой доставки (JSONB) ## Сервисы: - ChatSettingsService: управление настройками чата - BanService: управление банами пользователей - ChatMessageService: работа с историей сообщений - ChatPermissionService: проверка прав на отправку сообщений ## Обработчики: - chat_handlers.py: обработка сообщений пользователей (7 типов контента) - admin_chat_handlers.py: админские команды управления чатом ## Админские команды: - /chat_mode - переключение режима (broadcast/forward) - /set_forward - установка ID канала для пересылки - /ban [причина] - бан пользователя - /unban - разбан пользователя - /banlist - список забаненных - /global_ban - включение/выключение глобального бана - /delete_msg - удаление сообщения (ответ на сообщение) - /chat_stats - статистика чата ## Документация: - docs/CHAT_SYSTEM.md: полное описание системы с примерами использования Изменено файлов: 7 (2 modified, 5 new) - main.py: подключены chat_router и admin_chat_router - src/core/models.py: добавлены модели ChatSettings, BannedUser, ChatMessage - migrations/versions/005_add_chat_system.py: миграция создания таблиц - src/core/chat_services.py: сервисный слой для чата (267 строк) - src/handlers/chat_handlers.py: обработчики сообщений (447 строк) - src/handlers/admin_chat_handlers.py: админские команды (369 строк) - docs/CHAT_SYSTEM.md: документация (390 строк) --- docs/CHAT_SYSTEM.md | 355 +++++++++++++++++ main.py | 4 + migrations/versions/005_add_chat_system.py | 91 +++++ src/core/chat_services.py | 270 +++++++++++++ src/core/models.py | 61 ++- src/handlers/admin_chat_handlers.py | 374 ++++++++++++++++++ src/handlers/chat_handlers.py | 438 +++++++++++++++++++++ 7 files changed, 1592 insertions(+), 1 deletion(-) create mode 100644 docs/CHAT_SYSTEM.md create mode 100644 migrations/versions/005_add_chat_system.py create mode 100644 src/core/chat_services.py create mode 100644 src/handlers/admin_chat_handlers.py create mode 100644 src/handlers/chat_handlers.py diff --git a/docs/CHAT_SYSTEM.md b/docs/CHAT_SYSTEM.md new file mode 100644 index 0000000..e686083 --- /dev/null +++ b/docs/CHAT_SYSTEM.md @@ -0,0 +1,355 @@ +# Система чата пользователей + +## Описание + +Система чата позволяет пользователям общаться между собой через бота с двумя режимами работы: +- **Broadcast (Рассылка)** - сообщения пользователей рассылаются всем остальным пользователям +- **Forward (Пересылка)** - сообщения пользователей пересылаются в указанную группу/канал + +## Режимы работы + +### Режим Broadcast (Рассылка всем) + +В этом режиме сообщения от одного пользователя автоматически рассылаются всем остальным активным пользователям бота. + +**Особенности:** +- Отправитель не получает копию своего сообщения +- Сообщение доставляется только активным пользователям (is_active=True) +- В базу сохраняется статистика доставки (кому доставлено, кому нет) +- ID отправленных сообщений сохраняются в `forwarded_message_ids` (JSONB) + +**Пример работы:** +1. Пользователь А отправляет фото с текстом "Привет всем!" +2. Бот копирует это сообщение пользователям B, C, D... +3. В базу сохраняется: `{telegram_id_B: msg_id_1, telegram_id_C: msg_id_2, ...}` +4. Пользователю А показывается статистика: "✅ Сообщение разослано! 📤 Доставлено: 15, ❌ Не доставлено: 2" + +### Режим Forward (Пересылка в канал) + +В этом режиме сообщения от пользователей пересылаются в указанную группу или канал. + +**Особенности:** +- Бот должен быть администратором канала/группы с правом публикации +- Сохраняется оригинальное авторство сообщения (пересылка, а не копия) +- ID канала хранится в `chat_settings.forward_chat_id` +- В базу сохраняется ID сообщения в канале + +**Пример работы:** +1. Пользователь отправляет видео +2. Бот пересылает это видео в канал (сохраняя имя отправителя) +3. В базу сохраняется: `{channel: message_id_in_channel}` +4. Пользователю показывается: "✅ Сообщение переслано в канал" + +## Поддерживаемые типы сообщений + +Система поддерживает все основные типы контента: + +| Тип | Поле `message_type` | Поле `file_id` | Описание | +|-----|---------------------|----------------|----------| +| Текст | `text` | NULL | Обычное текстовое сообщение | +| Фото | `photo` | file_id | Изображение (сохраняется самое большое) | +| Видео | `video` | file_id | Видео файл | +| Документ | `document` | file_id | Файл любого типа | +| GIF | `animation` | file_id | Анимированное изображение | +| Стикер | `sticker` | file_id | Стикер из набора | +| Голосовое | `voice` | file_id | Голосовое сообщение | + +**Примечание:** Для всех типов кроме `text` и `sticker` может быть указан `caption` (подпись), который сохраняется в поле `text`. + +## Система банов + +### Личный бан пользователя + +Администратор может забанить конкретного пользователя: + +``` +/ban 123456789 Спам в чате +/ban (ответ на сообщение) Нарушение правил +``` + +**Эффекты:** +- Пользователь не может отправлять сообщения +- При попытке отправки получает: "❌ Вы заблокированы и не можете отправлять сообщения" +- Запись добавляется в таблицу `banned_users` с `is_active=true` + +**Разблокировка:** +``` +/unban 123456789 +/unban (ответ на сообщение) +``` + +### Глобальный бан чата + +Администратор может временно закрыть весь чат: + +``` +/global_ban +``` + +**Эффекты:** +- Все пользователи (кроме админов) не могут писать +- При попытке отправки: "❌ Чат временно закрыт администратором" +- Флаг `chat_settings.global_ban` устанавливается в `true` + +**Открытие чата:** +``` +/global_ban (повторно - переключение) +``` + +## Модерация сообщений + +### Удаление сообщений + +Администратор может удалить сообщение из всех чатов: + +``` +/delete_msg (ответ на сообщение) +``` + +**Процесс:** +1. Сообщение помечается как удаленное в БД (`is_deleted=true`) +2. Сохраняется кто удалил (`deleted_by`) и когда (`deleted_at`) +3. Бот пытается удалить сообщение у всех пользователей, используя `forwarded_message_ids` +4. Показывается статистика: "✅ Удалено у 12 пользователей" + +**Важно:** Удаление возможно только если сообщение было сохранено в БД и есть `forwarded_message_ids`. + +## Админские команды + +### /chat_mode +Переключение режима работы чата. + +**Интерфейс:** Inline-клавиатура с выбором режима. + +**Пример использования:** +``` +/chat_mode +→ Показывается меню выбора режима +→ Нажимаем "📢 Рассылка всем" +→ Режим изменен +``` + +### /set_forward +Установить ID канала/группы для пересылки. + +**Как узнать chat_id:** +1. Добавьте бота в канал/группу +2. Напишите любое сообщение в канале +3. Перешлите его боту @userinfobot +4. Он покажет chat_id (например: -1001234567890) + +**Пример:** +``` +/set_forward -1001234567890 +→ ID канала для пересылки установлен! +``` + +### /ban [причина] +Забанить пользователя. + +**Способы использования:** +1. Ответить на сообщение: `/ban Спам` +2. Указать ID: `/ban 123456789 Нарушение правил` + +### /unban +Разбанить пользователя. + +**Способы использования:** +1. Ответить на сообщение: `/unban` +2. Указать ID: `/unban 123456789` + +### /banlist +Показать список всех забаненных пользователей. + +**Формат вывода:** +``` +🚫 Забаненные пользователи + +👤 Иван Иванов (123456789) +🔨 Забанил: Админ +📝 Причина: Спам +📅 Дата: 15.01.2025 14:30 + +👤 Петр Петров (987654321) +🔨 Забанил: Админ +📅 Дата: 14.01.2025 12:00 +``` + +### /global_ban +Включить/выключить глобальный бан чата (переключатель). + +**Статусы:** +- 🔇 Включен - только админы могут писать +- 🔊 Выключен - все могут писать + +### /delete_msg +Удалить сообщение (ответ на сообщение). + +**Требует:** Ответить на сообщение, которое нужно удалить. + +### /chat_stats +Показать статистику чата. + +**Информация:** +- Текущий режим работы +- Статус глобального бана +- Количество забаненных пользователей +- Количество сообщений за последнее время +- ID канала (если установлен) + +## База данных + +### Таблица chat_settings + +Одна строка с глобальными настройками чата: + +```sql +id = 1 (всегда) +mode = 'broadcast' | 'forward' +forward_chat_id = '-1001234567890' (для режима forward) +global_ban = true | false +``` + +### Таблица banned_users + +История банов пользователей: + +```sql +id - уникальный ID бана +user_id - FK на users.id +telegram_id - Telegram ID пользователя +banned_by - FK на users.id (кто забанил) +reason - текстовая причина (nullable) +banned_at - timestamp бана +is_active - true/false (активен ли бан) +``` + +**Примечание:** При разбане `is_active` меняется на `false`, но запись не удаляется (история). + +### Таблица chat_messages + +История всех отправленных сообщений: + +```sql +id - уникальный ID сообщения +user_id - FK на users.id (отправитель) +telegram_message_id - ID сообщения в Telegram +message_type - text/photo/video/document/animation/sticker/voice +text - текст или caption (nullable) +file_id - file_id медиа (nullable) +forwarded_message_ids - JSONB с картой доставки +is_deleted - помечено ли как удаленное +deleted_by - FK на users.id (кто удалил, nullable) +deleted_at - timestamp удаления (nullable) +created_at - timestamp отправки +``` + +**Формат forwarded_message_ids:** +```json +// Режим broadcast: +{ + "123456789": 12345, // telegram_id: message_id + "987654321": 12346, + "555555555": 12347 +} + +// Режим forward: +{ + "channel": 54321 // ключ "channel", значение - ID сообщения в канале +} +``` + +## Примеры использования + +### Настройка режима broadcast + +1. Админ: `/chat_mode` → выбирает "📢 Рассылка всем" +2. Пользователь А пишет: "Привет всем!" +3. Пользователи B, C, D получают это сообщение +4. Пользователь А видит: "✅ Сообщение разослано! 📤 Доставлено: 3" + +### Настройка режима forward + +1. Админ создает канал и добавляет бота как админа +2. Админ узнает chat_id канала (например: -1001234567890) +3. Админ: `/set_forward -1001234567890` +4. Админ: `/chat_mode` → выбирает "➡️ Пересылка в канал" +5. Пользователь пишет сообщение → оно появляется в канале + +### Бан пользователя за спам + +1. Пользователь отправляет спам +2. Админ отвечает на его сообщение: `/ban Спам в чате` +3. Пользователь забанен, попытки отправить сообщение блокируются +4. Админ: `/banlist` - видит список банов +5. Админ: `/unban` (ответ на сообщение) - разбан + +### Временное закрытие чата + +1. Админ: `/global_ban` +2. Все пользователи видят: "❌ Чат временно закрыт администратором" +3. Только админы могут писать +4. Админ: `/global_ban` (повторно) - чат открыт + +### Удаление неприемлемого контента + +1. Пользователь отправил неприемлемое фото +2. Фото разослано всем (режим broadcast) +3. Админ отвечает на это сообщение: `/delete_msg` +4. Бот удаляет фото у всех пользователей, кому оно было отправлено +5. В БД сообщение помечается как удаленное + +## Технические детали + +### Порядок подключения роутеров + +```python +dp.include_router(registration_router) # Первым +dp.include_router(admin_account_router) +dp.include_router(admin_chat_router) # До chat_router! +dp.include_router(redraw_router) +dp.include_router(account_router) +dp.include_router(chat_router) # ПОСЛЕДНИМ (ловит все сообщения) +dp.include_router(router) +dp.include_router(admin_router) +``` + +**Важно:** `chat_router` должен быть последним, так как он ловит ВСЕ типы сообщений (text, photo, video и т.д.). Если поставить его раньше, он будет перехватывать команды и сообщения, предназначенные для других обработчиков. + +### Проверка прав + +```python +can_send, reason = await ChatPermissionService.can_send_message( + session, + telegram_id=user.telegram_id, + is_admin=is_admin(user.telegram_id) +) +``` + +**Логика проверки:** +1. Если пользователь админ → всегда `can_send=True` +2. Если включен global_ban → `can_send=False` +3. Если пользователь забанен → `can_send=False` +4. Иначе → `can_send=True` + +### Миграция 005 + +При запуске миграции создаются 3 таблицы и вставляется начальная запись: + +```sql +INSERT INTO chat_settings (id, mode, global_ban) +VALUES (1, 'broadcast', false); +``` + +Эта запись будет использоваться всегда (единственная строка в таблице). + +## Возможные улучшения + +1. **Фильтрация контента** - автоматическая проверка на мат, спам, ссылки +2. **Лимиты** - ограничение количества сообщений в минуту/час +3. **Ответы на сообщения** - возможность отвечать на конкретное сообщение пользователя +4. **Редактирование** - изменение отправленных сообщений +5. **Реакции** - лайки/дизлайки на сообщения +6. **Каналы** - разделение чата на темы/каналы +7. **История** - просмотр истории сообщений через команду +8. **Поиск** - поиск по истории сообщений diff --git a/main.py b/main.py index aba7d52..e73359c 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,8 @@ from src.handlers.account_handlers import account_router from src.handlers.registration_handlers import router as registration_router from src.handlers.admin_account_handlers import router as admin_account_router from src.handlers.redraw_handlers import router as redraw_router +from src.handlers.chat_handlers import router as chat_router +from src.handlers.admin_chat_handlers import router as admin_chat_router from src.utils.async_decorators import ( async_user_action, admin_async_action, db_operation, TaskManagerMiddleware, shutdown_task_manager, @@ -1007,8 +1009,10 @@ async def main(): # Подключение роутеров dp.include_router(registration_router) # Роутер регистрации (первый) dp.include_router(admin_account_router) # Роутер админских команд для счетов + dp.include_router(admin_chat_router) # Роутер админских команд чата (до обычных обработчиков) dp.include_router(redraw_router) # Роутер повторного розыгрыша dp.include_router(account_router) # Роутер для работы со счетами + dp.include_router(chat_router) # Роутер чата пользователей (ПОСЛЕДНИМ!) dp.include_router(router) dp.include_router(admin_router) diff --git a/migrations/versions/005_add_chat_system.py b/migrations/versions/005_add_chat_system.py new file mode 100644 index 0000000..f192085 --- /dev/null +++ b/migrations/versions/005_add_chat_system.py @@ -0,0 +1,91 @@ +"""Add chat system tables + +Revision ID: 005 +Revises: 004 +Create Date: 2025-11-16 14:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '005' +down_revision = '004' +branch_labels = None +depends_on = None + + +def upgrade(): + # Таблица настроек чата + op.create_table( + 'chat_settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('mode', sa.String(), nullable=False, server_default='broadcast'), # broadcast или forward + sa.Column('forward_chat_id', sa.String(), nullable=True), # ID группы/канала для пересылки + sa.Column('global_ban', sa.Boolean(), nullable=False, server_default='false'), # Глобальный бан чата + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + # Вставляем дефолтные настройки + op.execute( + "INSERT INTO chat_settings (id, mode, global_ban) VALUES (1, 'broadcast', false)" + ) + + # Таблица забаненных пользователей + op.create_table( + 'banned_users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), # ID пользователя в системе + sa.Column('telegram_id', sa.BigInteger(), nullable=False), # Telegram ID + sa.Column('banned_by', sa.Integer(), nullable=False), # ID админа + sa.Column('reason', sa.Text(), nullable=True), # Причина бана + sa.Column('banned_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), # Активен ли бан + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['banned_by'], ['users.id'], ondelete='SET NULL') + ) + + # Индексы для быстрого поиска + op.create_index('ix_banned_users_telegram_id', 'banned_users', ['telegram_id']) + op.create_index('ix_banned_users_is_active', 'banned_users', ['is_active']) + + # Таблица сообщений чата (для хранения истории и модерации) + op.create_table( + 'chat_messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), # Отправитель + sa.Column('telegram_message_id', sa.Integer(), nullable=False), # ID сообщения в Telegram + sa.Column('message_type', sa.String(), nullable=False), # text, photo, video, document, etc. + sa.Column('text', sa.Text(), nullable=True), # Текст сообщения + sa.Column('file_id', sa.String(), nullable=True), # ID файла в Telegram + sa.Column('forwarded_message_ids', postgresql.JSONB(), nullable=True), # Список ID пересланных сообщений + sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('deleted_by', sa.Integer(), nullable=True), # Кто удалил + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['deleted_by'], ['users.id'], ondelete='SET NULL') + ) + + # Индексы + op.create_index('ix_chat_messages_user_id', 'chat_messages', ['user_id']) + op.create_index('ix_chat_messages_created_at', 'chat_messages', ['created_at']) + op.create_index('ix_chat_messages_is_deleted', 'chat_messages', ['is_deleted']) + + +def downgrade(): + op.drop_index('ix_chat_messages_is_deleted', table_name='chat_messages') + op.drop_index('ix_chat_messages_created_at', table_name='chat_messages') + op.drop_index('ix_chat_messages_user_id', table_name='chat_messages') + op.drop_table('chat_messages') + + op.drop_index('ix_banned_users_is_active', table_name='banned_users') + op.drop_index('ix_banned_users_telegram_id', table_name='banned_users') + op.drop_table('banned_users') + + op.drop_table('chat_settings') diff --git a/src/core/chat_services.py b/src/core/chat_services.py new file mode 100644 index 0000000..8755fa3 --- /dev/null +++ b/src/core/chat_services.py @@ -0,0 +1,270 @@ +"""Сервисы для системы чата""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_, update, delete +from sqlalchemy.orm import selectinload +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone + +from .models import ChatSettings, BannedUser, ChatMessage, User + + +class ChatSettingsService: + """Сервис управления настройками чата""" + + @staticmethod + async def get_settings(session: AsyncSession) -> Optional[ChatSettings]: + """Получить текущие настройки чата""" + result = await session.execute( + select(ChatSettings).where(ChatSettings.id == 1) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_or_create_settings(session: AsyncSession) -> ChatSettings: + """Получить или создать настройки чата""" + settings = await ChatSettingsService.get_settings(session) + if not settings: + settings = ChatSettings(id=1, mode='broadcast', global_ban=False) + session.add(settings) + await session.commit() + await session.refresh(settings) + return settings + + @staticmethod + async def set_mode(session: AsyncSession, mode: str) -> ChatSettings: + """Установить режим работы чата (broadcast/forward)""" + settings = await ChatSettingsService.get_or_create_settings(session) + settings.mode = mode + settings.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(settings) + return settings + + @staticmethod + async def set_forward_chat(session: AsyncSession, chat_id: str) -> ChatSettings: + """Установить ID группы/канала для пересылки""" + settings = await ChatSettingsService.get_or_create_settings(session) + settings.forward_chat_id = chat_id + settings.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(settings) + return settings + + @staticmethod + async def set_global_ban(session: AsyncSession, enabled: bool) -> ChatSettings: + """Включить/выключить глобальный бан чата""" + settings = await ChatSettingsService.get_or_create_settings(session) + settings.global_ban = enabled + settings.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(settings) + return settings + + +class BanService: + """Сервис управления банами пользователей""" + + @staticmethod + async def is_banned(session: AsyncSession, telegram_id: int) -> bool: + """Проверить забанен ли пользователь""" + result = await session.execute( + select(BannedUser).where( + and_( + BannedUser.telegram_id == telegram_id, + BannedUser.is_active == True + ) + ) + ) + return result.scalar_one_or_none() is not None + + @staticmethod + async def ban_user( + session: AsyncSession, + user_id: int, + telegram_id: int, + banned_by: int, + reason: Optional[str] = None + ) -> BannedUser: + """Забанить пользователя""" + # Проверяем есть ли уже активный бан + existing_ban = await session.execute( + select(BannedUser).where( + and_( + BannedUser.telegram_id == telegram_id, + BannedUser.is_active == True + ) + ) + ) + existing = existing_ban.scalar_one_or_none() + + if existing: + # Обновляем причину + existing.reason = reason + existing.banned_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(existing) + return existing + + # Создаем новый бан + ban = BannedUser( + user_id=user_id, + telegram_id=telegram_id, + banned_by=banned_by, + reason=reason + ) + session.add(ban) + await session.commit() + await session.refresh(ban) + return ban + + @staticmethod + async def unban_user(session: AsyncSession, telegram_id: int) -> bool: + """Разбанить пользователя""" + result = await session.execute( + update(BannedUser) + .where( + and_( + BannedUser.telegram_id == telegram_id, + BannedUser.is_active == True + ) + ) + .values(is_active=False) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]: + """Получить список забаненных пользователей""" + query = select(BannedUser).options( + selectinload(BannedUser.user), + selectinload(BannedUser.admin) + ) + + if active_only: + query = query.where(BannedUser.is_active == True) + + result = await session.execute(query.order_by(BannedUser.banned_at.desc())) + return result.scalars().all() + + +class ChatMessageService: + """Сервис работы с сообщениями чата""" + + @staticmethod + async def save_message( + session: AsyncSession, + user_id: int, + telegram_message_id: int, + message_type: str, + text: Optional[str] = None, + file_id: Optional[str] = None, + forwarded_ids: Optional[Dict[str, int]] = None + ) -> ChatMessage: + """Сохранить сообщение в историю""" + message = ChatMessage( + user_id=user_id, + telegram_message_id=telegram_message_id, + message_type=message_type, + text=text, + file_id=file_id, + forwarded_message_ids=forwarded_ids + ) + session.add(message) + await session.commit() + await session.refresh(message) + return message + + @staticmethod + async def get_message(session: AsyncSession, message_id: int) -> Optional[ChatMessage]: + """Получить сообщение по ID""" + result = await session.execute( + select(ChatMessage) + .options(selectinload(ChatMessage.sender)) + .where(ChatMessage.id == message_id) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_user_messages( + session: AsyncSession, + user_id: int, + limit: int = 50, + include_deleted: bool = False + ) -> List[ChatMessage]: + """Получить сообщения пользователя""" + query = select(ChatMessage).where(ChatMessage.user_id == user_id) + + if not include_deleted: + query = query.where(ChatMessage.is_deleted == False) + + query = query.order_by(ChatMessage.created_at.desc()).limit(limit) + + result = await session.execute(query) + return result.scalars().all() + + @staticmethod + async def delete_message( + 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 + + @staticmethod + async def get_recent_messages( + session: AsyncSession, + limit: int = 100, + 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) + + result = await session.execute(query) + return result.scalars().all() + + +class ChatPermissionService: + """Сервис проверки прав на отправку сообщений""" + + @staticmethod + async def can_send_message( + session: AsyncSession, + telegram_id: int, + is_admin: bool = False + ) -> tuple[bool, Optional[str]]: + """ + Проверить может ли пользователь отправлять сообщения + Возвращает (разрешено, причина_отказа) + """ + # Админы всегда могут отправлять + if is_admin: + return True, None + + # Проверяем глобальный бан + settings = await ChatSettingsService.get_settings(session) + if settings and settings.global_ban: + return False, "Чат временно закрыт администратором" + + # Проверяем личный бан + is_banned = await BanService.is_banned(session, telegram_id) + if is_banned: + return False, "Вы заблокированы и не можете отправлять сообщения" + + return True, None diff --git a/src/core/models.py b/src/core/models.py index 2b09ec1..d30a870 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -156,4 +156,63 @@ class Winner(Base): def __repr__(self): if self.account_number: return f"" - return f"" \ No newline at end of file + return f"" + + +class ChatSettings(Base): + """Настройки системы чата""" + __tablename__ = "chat_settings" + + id = Column(Integer, primary_key=True) + mode = Column(String(20), nullable=False, default='broadcast') # broadcast или forward + forward_chat_id = Column(String(50), nullable=True) # ID группы/канала для пересылки + global_ban = Column(Boolean, default=False) # Глобальный бан чата + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def __repr__(self): + return f"" + + +class BannedUser(Base): + """Забаненные пользователи (не могут отправлять сообщения)""" + __tablename__ = "banned_users" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + telegram_id = Column(Integer, nullable=False, index=True) + banned_by = Column(Integer, ForeignKey("users.id"), nullable=False) + reason = Column(Text, nullable=True) + banned_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + is_active = Column(Boolean, default=True, index=True) # Активен ли бан + + # Связи + user = relationship("User", foreign_keys=[user_id]) + admin = relationship("User", foreign_keys=[banned_by]) + + def __repr__(self): + return f"" + + +class ChatMessage(Base): + """История сообщений чата (для модерации)""" + __tablename__ = "chat_messages" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + telegram_message_id = Column(Integer, nullable=False) + message_type = Column(String(20), nullable=False) # text, photo, video, document, animation, sticker, voice, etc. + text = Column(Text, nullable=True) # Текст сообщения + file_id = Column(String(255), nullable=True) # ID файла в Telegram + forwarded_message_ids = Column(JSON, nullable=True) # Список telegram_message_id пересланных сообщений {"user_telegram_id": message_id} + is_deleted = Column(Boolean, default=False, index=True) + deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + + # Связи + sender = relationship("User", foreign_keys=[user_id]) + moderator = relationship("User", foreign_keys=[deleted_by]) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/src/handlers/admin_chat_handlers.py b/src/handlers/admin_chat_handlers.py new file mode 100644 index 0000000..0570fda --- /dev/null +++ b/src/handlers/admin_chat_handlers.py @@ -0,0 +1,374 @@ +"""Админские обработчики для управления чатом""" +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.filters import Command +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.chat_services import ( + ChatSettingsService, + BanService, + ChatMessageService +) +from src.core.services import UserService +from database import get_session +from config import ADMIN_IDS + + +router = Router(name='admin_chat_router') + + +def is_admin(user_id: int) -> bool: + """Проверка является ли пользователь админом""" + return user_id in ADMIN_IDS + + +def get_chat_mode_keyboard() -> InlineKeyboardMarkup: + """Клавиатура выбора режима чата""" + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📢 Рассылка всем", callback_data="chat_mode:broadcast"), + InlineKeyboardButton(text="➡️ Пересылка в канал", callback_data="chat_mode:forward") + ], + [InlineKeyboardButton(text="❌ Закрыть", callback_data="close_menu")] + ]) + + +@router.message(Command("chat_mode")) +async def cmd_chat_mode(message: Message): + """Команда управления режимом чата""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + async for session in get_session(): + settings = await ChatSettingsService.get_or_create_settings(session) + + mode_text = "📢 Рассылка всем пользователям" if settings.mode == 'broadcast' else "➡️ Пересылка в канал" + + await message.answer( + f"🎛 Управление режимом чата\n\n" + f"Текущий режим: {mode_text}\n\n" + f"Выберите режим работы:", + reply_markup=get_chat_mode_keyboard(), + parse_mode="HTML" + ) + + +@router.callback_query(F.data.startswith("chat_mode:")) +async def process_chat_mode(callback: CallbackQuery): + """Обработка выбора режима чата""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ У вас нет прав", show_alert=True) + return + + mode = callback.data.split(":")[1] + + async for session in get_session(): + settings = await ChatSettingsService.set_mode(session, mode) + + mode_text = "📢 Рассылка всем пользователям" if mode == 'broadcast' else "➡️ Пересылка в канал" + + await callback.message.edit_text( + f"✅ Режим чата изменен!\n\n" + f"Новый режим: {mode_text}", + reply_markup=None + ) + + await callback.answer("✅ Режим изменен") + + +@router.message(Command("set_forward")) +async def cmd_set_forward(message: Message): + """Установить ID канала для пересылки""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + args = message.text.split(maxsplit=1) + if len(args) < 2: + await message.answer( + "📝 Использование:\n" + "/set_forward \n\n" + "Пример: /set_forward -1001234567890\n\n" + "💡 Чтобы узнать ID канала/группы:\n" + "1. Добавьте бота в канал/группу\n" + "2. Напишите любое сообщение\n" + "3. Перешлите его боту @userinfobot", + parse_mode="HTML" + ) + return + + chat_id = args[1].strip() + + async for session in get_session(): + settings = await ChatSettingsService.set_forward_chat(session, chat_id) + + await message.answer( + f"✅ ID канала для пересылки установлен!\n\n" + f"Chat ID: {chat_id}\n\n" + f"Теперь переключитесь в режим пересылки командой /chat_mode", + parse_mode="HTML" + ) + + +@router.message(Command("global_ban")) +async def cmd_global_ban(message: Message): + """Включить/выключить глобальный бан чата""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + async for session in get_session(): + settings = await ChatSettingsService.get_or_create_settings(session) + + # Переключаем состояние + new_state = not settings.global_ban + settings = await ChatSettingsService.set_global_ban(session, new_state) + + if new_state: + await message.answer( + "🔇 Глобальный бан включен\n\n" + "Теперь только администраторы могут отправлять сообщения в чат", + parse_mode="HTML" + ) + else: + await message.answer( + "🔊 Глобальный бан выключен\n\n" + "Все пользователи снова могут отправлять сообщения", + parse_mode="HTML" + ) + + +@router.message(Command("ban")) +async def cmd_ban(message: Message): + """Забанить пользователя""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + # Проверяем является ли это ответом на сообщение + if message.reply_to_message: + target_user_id = message.reply_to_message.from_user.id + reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None + else: + args = message.text.split(maxsplit=2) + if len(args) < 2: + await message.answer( + "📝 Использование:\n\n" + "1. Ответьте на сообщение пользователя: /ban [причина]\n" + "2. Укажите ID: /ban [причина]\n\n" + "Пример: /ban 123456789 Спам", + parse_mode="HTML" + ) + return + + try: + target_user_id = int(args[1]) + reason = args[2] if len(args) > 2 else None + except ValueError: + await message.answer("❌ Неверный ID пользователя") + return + + async for session in get_session(): + # Получаем пользователя + user = await UserService.get_user_by_telegram_id(session, target_user_id) + + if not user: + await message.answer("❌ Пользователь не найден в базе") + return + + # Получаем админа + admin = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + # Баним + ban = await BanService.ban_user( + session, + user_id=user.id, + telegram_id=target_user_id, + banned_by=admin.id, + reason=reason + ) + + reason_text = f"\n📝 Причина: {reason}" if reason else "" + + await message.answer( + f"🚫 Пользователь забанен\n\n" + f"👤 Пользователь: {user.name or 'Неизвестен'}\n" + f"🆔 ID: {target_user_id}" + f"{reason_text}", + parse_mode="HTML" + ) + + +@router.message(Command("unban")) +async def cmd_unban(message: Message): + """Разбанить пользователя""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + # Проверяем является ли это ответом на сообщение + if message.reply_to_message: + target_user_id = message.reply_to_message.from_user.id + else: + args = message.text.split() + if len(args) < 2: + await message.answer( + "📝 Использование:\n\n" + "1. Ответьте на сообщение пользователя: /unban\n" + "2. Укажите ID: /unban \n\n" + "Пример: /unban 123456789", + parse_mode="HTML" + ) + return + + try: + target_user_id = int(args[1]) + except ValueError: + await message.answer("❌ Неверный ID пользователя") + return + + async for session in get_session(): + # Разбаниваем + success = await BanService.unban_user(session, target_user_id) + + if success: + await message.answer( + f"✅ Пользователь разбанен\n\n" + f"🆔 ID: {target_user_id}\n\n" + f"Теперь пользователь может отправлять сообщения", + parse_mode="HTML" + ) + else: + await message.answer("❌ Пользователь не был забанен") + + +@router.message(Command("banlist")) +async def cmd_banlist(message: Message): + """Показать список забаненных пользователей""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + async for session in get_session(): + banned_users = await BanService.get_banned_users(session, active_only=True) + + if not banned_users: + await message.answer("📋 Список банов пуст") + return + + text = "🚫 Забаненные пользователи\n\n" + + for ban in banned_users: + user = ban.user + admin = ban.admin + + text += f"👤 {user.name or 'Неизвестен'} ({ban.telegram_id})\n" + text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n" + + if ban.reason: + text += f"📝 Причина: {ban.reason}\n" + + text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n" + text += "\n" + + await message.answer(text, parse_mode="HTML") + + +@router.message(Command("delete_msg")) +async def cmd_delete_message(message: Message): + """Удалить сообщение из чата (пометить как удаленное)""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + if not message.reply_to_message: + await message.answer( + "📝 Использование:\n\n" + "Ответьте на сообщение которое хотите удалить командой /delete_msg", + parse_mode="HTML" + ) + return + + async for session in get_session(): + # Получаем админа + admin = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + # Находим сообщение в базе по telegram_message_id + from sqlalchemy import select + from src.core.models import ChatMessage + + result = await session.execute( + select(ChatMessage).where( + ChatMessage.telegram_message_id == message.reply_to_message.message_id + ) + ) + chat_message = result.scalar_one_or_none() + + if not chat_message: + await message.answer("❌ Сообщение не найдено в базе данных") + return + + # Помечаем как удаленное + success = await ChatMessageService.delete_message( + session, + message_id=chat_message.id, + deleted_by=admin.id + ) + + if success: + # Пытаемся удалить сообщение у всех пользователей + if chat_message.forwarded_message_ids: + deleted_count = 0 + for user_telegram_id, msg_id in chat_message.forwarded_message_ids.items(): + try: + await message.bot.delete_message(int(user_telegram_id), msg_id) + deleted_count += 1 + except Exception as e: + print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}") + + await message.answer( + f"✅ Сообщение удалено\n\n" + f"🗑 Удалено у {deleted_count} пользователей", + parse_mode="HTML" + ) + else: + await message.answer("✅ Сообщение помечено как удаленное") + else: + await message.answer("❌ Не удалось удалить сообщение") + + +@router.message(Command("chat_stats")) +async def cmd_chat_stats(message: Message): + """Статистика чата""" + if not is_admin(message.from_user.id): + await message.answer("❌ У вас нет прав для выполнения этой команды") + return + + async for session in get_session(): + settings = await ChatSettingsService.get_or_create_settings(session) + banned_users = await BanService.get_banned_users(session, active_only=True) + recent_messages = await ChatMessageService.get_recent_messages(session, limit=100) + + mode_text = "📢 Рассылка всем" if settings.mode == 'broadcast' else "➡️ Пересылка в канал" + global_ban_text = "🔇 Включен" if settings.global_ban else "🔊 Выключен" + + text = ( + f"📊 Статистика чата\n\n" + f"🎛 Режим: {mode_text}\n" + f"🚫 Глобальный бан: {global_ban_text}\n" + f"👥 Забанено пользователей: {len(banned_users)}\n" + f"💬 Сообщений за последнее время: {len(recent_messages)}\n" + ) + + if settings.mode == 'forward' and settings.forward_chat_id: + text += f"\n➡️ ID канала: {settings.forward_chat_id}" + + await message.answer(text, parse_mode="HTML") + + +@router.callback_query(F.data == "close_menu") +async def close_menu(callback: CallbackQuery): + """Закрыть меню""" + await callback.message.delete() + await callback.answer() diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py new file mode 100644 index 0000000..4effce1 --- /dev/null +++ b/src/handlers/chat_handlers.py @@ -0,0 +1,438 @@ +"""Обработчики пользовательских сообщений в чате""" +from aiogram import Router, F +from aiogram.types import Message +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.chat_services import ( + ChatSettingsService, + ChatPermissionService, + ChatMessageService, + BanService +) +from src.core.services import UserService +from database import get_session +from config import ADMIN_IDS + + +def is_admin(user_id: int) -> bool: + """Проверка является ли пользователь админом""" + return user_id in ADMIN_IDS + + +router = Router(name='chat_router') + + +async def get_all_active_users(session: AsyncSession): + """Получить всех активных пользователей для рассылки""" + users = await UserService.get_all_users(session) + return [u for u in users if u.is_active] + + +async def broadcast_message(message: Message, exclude_user_id: int = None): + """Разослать сообщение всем пользователям""" + async for session in get_session(): + users = await get_all_active_users(session) + + forwarded_ids = {} + success_count = 0 + fail_count = 0 + + for user in users: + if exclude_user_id and user.telegram_id == exclude_user_id: + continue + + try: + # Копируем сообщение пользователю + sent_msg = await message.copy_to(user.telegram_id) + forwarded_ids[str(user.telegram_id)] = sent_msg.message_id + success_count += 1 + except Exception as e: + fail_count += 1 + print(f"Failed to send message to {user.telegram_id}: {e}") + + return forwarded_ids, success_count, fail_count + + +async def forward_to_channel(message: Message, channel_id: str): + """Переслать сообщение в канал/группу""" + try: + # Пересылаем сообщение в канал + sent_msg = await message.forward(channel_id) + return True, sent_msg.message_id + except Exception as e: + print(f"Failed to forward message to channel {channel_id}: {e}") + return False, None + + +@router.message(F.text) +async def handle_text_message(message: Message): + """Обработчик текстовых сообщений""" + async for session in get_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: + await message.answer("❌ Пользователь не найден") + return + + # Обрабатываем в зависимости от режима + if settings.mode == 'broadcast': + # Режим рассылки + forwarded_ids, success, fail = await broadcast_message(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='text', + text=message.text, + forwarded_ids=forwarded_ids + ) + + await message.answer( + f"✅ Сообщение разослано!\n" + f"📤 Доставлено: {success}\n" + f"❌ Не доставлено: {fail}" + ) + + elif settings.mode == 'forward': + # Режим пересылки в канал + if not settings.forward_chat_id: + await message.answer("❌ Канал для пересылки не настроен") + return + + 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='text', + text=message.text, + forwarded_ids={'channel': channel_msg_id} + ) + + await message.answer("✅ Сообщение переслано в канал") + else: + await message.answer("❌ Не удалось переслать сообщение") + + +@router.message(F.photo) +async def handle_photo_message(message: Message): + """Обработчик фото""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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 + + # Получаем file_id самого большого фото + photo = message.photo[-1] + + if settings.mode == 'broadcast': + forwarded_ids, success, fail = await broadcast_message(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='photo', + text=message.caption, + file_id=photo.file_id, + forwarded_ids=forwarded_ids + ) + + 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='photo', + text=message.caption, + file_id=photo.file_id, + forwarded_ids={'channel': channel_msg_id} + ) + await message.answer("✅ Фото переслано в канал") + + +@router.message(F.video) +async def handle_video_message(message: Message): + """Обработчик видео""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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(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='video', + text=message.caption, + file_id=message.video.file_id, + forwarded_ids=forwarded_ids + ) + + 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='video', + text=message.caption, + file_id=message.video.file_id, + forwarded_ids={'channel': channel_msg_id} + ) + await message.answer("✅ Видео переслано в канал") + + +@router.message(F.document) +async def handle_document_message(message: Message): + """Обработчик документов""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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(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='document', + text=message.caption, + file_id=message.document.file_id, + forwarded_ids=forwarded_ids + ) + + 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='document', + text=message.caption, + file_id=message.document.file_id, + forwarded_ids={'channel': channel_msg_id} + ) + await message.answer("✅ Документ переслан в канал") + + +@router.message(F.animation) +async def handle_animation_message(message: Message): + """Обработчик GIF анимаций""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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(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='animation', + text=message.caption, + file_id=message.animation.file_id, + forwarded_ids=forwarded_ids + ) + + 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='animation', + text=message.caption, + file_id=message.animation.file_id, + forwarded_ids={'channel': channel_msg_id} + ) + await message.answer("✅ Анимация переслана в канал") + + +@router.message(F.sticker) +async def handle_sticker_message(message: Message): + """Обработчик стикеров""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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(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='sticker', + file_id=message.sticker.file_id, + forwarded_ids=forwarded_ids + ) + + 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='sticker', + file_id=message.sticker.file_id, + forwarded_ids={'channel': channel_msg_id} + ) + await message.answer("✅ Стикер переслан в канал") + + +@router.message(F.voice) +async def handle_voice_message(message: Message): + """Обработчик голосовых сообщений""" + async for session in get_session(): + can_send, reason = await ChatPermissionService.can_send_message( + session, + 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(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 + ) + + 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} + ) + await message.answer("✅ Голосовое сообщение переслано в канал")