init commit
This commit is contained in:
122
app/__init__.py
Normal file
122
app/__init__.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
CallbackQueryHandler,
|
||||
ChatMemberHandler,
|
||||
ConversationHandler,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
from app.database import init_db
|
||||
from app.handlers import (
|
||||
start,
|
||||
help_command,
|
||||
start_callback,
|
||||
manage_messages,
|
||||
manage_groups,
|
||||
list_messages,
|
||||
list_groups,
|
||||
send_message,
|
||||
my_chat_member,
|
||||
)
|
||||
from app.handlers.message_manager import (
|
||||
create_message_start,
|
||||
create_message_title,
|
||||
create_message_text,
|
||||
select_groups,
|
||||
CREATE_MSG_TITLE,
|
||||
CREATE_MSG_TEXT,
|
||||
SELECT_GROUPS,
|
||||
)
|
||||
from app.handlers.telethon_client import telethon_manager
|
||||
from app.utils.keyboards import CallbackType
|
||||
from app.settings import Config
|
||||
|
||||
# Загружаем переменные окружения
|
||||
load_dotenv()
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Получаем конфигурацию
|
||||
if not Config.validate():
|
||||
raise ValueError("❌ Конфигурация некорректна. Проверьте .env файл")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Запуск бота с поддержкой гибридного режима"""
|
||||
|
||||
# Инициализируем БД
|
||||
logger.info("Инициализация базы данных...")
|
||||
await init_db()
|
||||
logger.info("✅ База данных инициализирована")
|
||||
|
||||
# Инициализируем Telethon если включен
|
||||
if Config.USE_TELETHON:
|
||||
logger.info("Инициализация Telethon клиента...")
|
||||
success = await telethon_manager.initialize()
|
||||
if success:
|
||||
logger.info("✅ Telethon клиент инициализирован")
|
||||
else:
|
||||
logger.warning("⚠️ Ошибка инициализации Telethon, продолжим с режимом бота")
|
||||
|
||||
# Выводим информацию о режиме
|
||||
mode = Config.get_mode()
|
||||
logger.info(f"📡 Режим работы: {mode}")
|
||||
if mode == 'hybrid':
|
||||
logger.info("🔀 Бот будет использовать Telethon как fallback для закрытых групп")
|
||||
|
||||
# Создаем приложение
|
||||
application = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build()
|
||||
|
||||
# Добавляем обработчики команд
|
||||
application.add_handler(CommandHandler("start", start))
|
||||
application.add_handler(CommandHandler("help", help_command))
|
||||
|
||||
# ConversationHandler для создания сообщения
|
||||
create_message_handler = ConversationHandler(
|
||||
entry_points=[CallbackQueryHandler(create_message_start, pattern=f"^{CallbackType.CREATE_MESSAGE}$")],
|
||||
states={
|
||||
CREATE_MSG_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, create_message_title)],
|
||||
CREATE_MSG_TEXT: [MessageHandler(filters.TEXT & ~filters.COMMAND, create_message_text)],
|
||||
SELECT_GROUPS: [CallbackQueryHandler(select_groups, pattern=r"^(select_group_\d+|done_groups|main_menu)$")],
|
||||
},
|
||||
fallbacks=[CommandHandler("cancel", start)],
|
||||
)
|
||||
application.add_handler(create_message_handler)
|
||||
|
||||
# Добавляем обработчики callback'ов
|
||||
application.add_handler(CallbackQueryHandler(start_callback, pattern=f"^{CallbackType.MAIN_MENU}$"))
|
||||
application.add_handler(CallbackQueryHandler(manage_messages, pattern=f"^{CallbackType.MANAGE_MESSAGES}$"))
|
||||
application.add_handler(CallbackQueryHandler(manage_groups, pattern=f"^{CallbackType.MANAGE_GROUPS}$"))
|
||||
application.add_handler(CallbackQueryHandler(list_messages, pattern=f"^{CallbackType.LIST_MESSAGES}$"))
|
||||
application.add_handler(CallbackQueryHandler(list_groups, pattern=f"^{CallbackType.LIST_GROUPS}$"))
|
||||
|
||||
# Отправка сообщений
|
||||
application.add_handler(CallbackQueryHandler(send_message, pattern=r"^send_msg_\d+$"))
|
||||
|
||||
# Обработчик добавления/удаления бота из групп
|
||||
application.add_handler(ChatMemberHandler(my_chat_member, ChatMemberHandler.MY_CHAT_MEMBER))
|
||||
|
||||
# Запускаем бота
|
||||
logger.info("🚀 Бот запущен. Ожидание команд...")
|
||||
try:
|
||||
await application.run_polling(allowed_updates=["message", "callback_query", "my_chat_member"])
|
||||
finally:
|
||||
# Завершить Telethon клиент при выходе
|
||||
if Config.USE_TELETHON:
|
||||
logger.info("Завершение работы Telethon клиента...")
|
||||
await telethon_manager.shutdown()
|
||||
logger.info("✅ Telethon клиент остановлен")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
||||
20
app/__main__.py
Normal file
20
app/__main__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Точка входа для запуска приложения как модуля Python
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Бот остановлен пользователем")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logging.error(f"Критическая ошибка: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
39
app/celery_config.py
Normal file
39
app/celery_config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Celery конфигурация для асинхронных задач
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from app.settings import Config
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создать Celery приложение
|
||||
celery_app = Celery(
|
||||
'tg_autoposter',
|
||||
broker=Config.CELERY_BROKER_URL,
|
||||
backend=Config.CELERY_RESULT_BACKEND_URL
|
||||
)
|
||||
|
||||
# Конфигурация
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=30 * 60, # 30 минут жесткий лимит
|
||||
task_soft_time_limit=25 * 60, # 25 минут мягкий лимит
|
||||
worker_prefetch_multiplier=1, # Брать по одной задаче
|
||||
worker_max_tasks_per_child=1000, # Перезагружать worker после 1000 задач
|
||||
)
|
||||
|
||||
# Маршруты для задач
|
||||
celery_app.conf.task_routes = {
|
||||
'app.celery_tasks.send_message_task': {'queue': 'messages'},
|
||||
'app.celery_tasks.parse_group_members_task': {'queue': 'parsing'},
|
||||
'app.celery_tasks.cleanup_old_messages_task': {'queue': 'maintenance'},
|
||||
}
|
||||
|
||||
logger.info("✅ Celery инициализирован")
|
||||
259
app/celery_tasks.py
Normal file
259
app/celery_tasks.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Celery задачи для асинхронной обработки
|
||||
"""
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.settings import Config
|
||||
from app.database.repository import GroupRepository, MessageRepository, MessageGroupRepository
|
||||
from app.database.member_repository import GroupMemberRepository, GroupStatisticsRepository
|
||||
from app.handlers.telethon_client import telethon_manager
|
||||
from app.handlers.group_parser import GroupParser
|
||||
from app.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
"""Получить сессию БД для Celery задач"""
|
||||
engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@shared_task(name='app.celery_tasks.send_message_task')
|
||||
def send_message_task(message_id: int, group_id: int, chat_id: str, message_text: str):
|
||||
"""
|
||||
Задача для отправки сообщения в группу
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения в БД
|
||||
group_id: ID группы в БД
|
||||
chat_id: ID чата в Telegram
|
||||
message_text: Текст сообщения
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
async def _send():
|
||||
# Инициализировать Telethon если необходимо
|
||||
if Config.USE_TELETHON and not telethon_manager.is_connected():
|
||||
await telethon_manager.initialize()
|
||||
|
||||
engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
from app.handlers.hybrid_sender import HybridMessageSender
|
||||
from telegram.ext import Application
|
||||
|
||||
# Получить Application (нужен для гибридного отправителя)
|
||||
# В Celery контексте создаем минималистичный объект
|
||||
app = type('obj', (object,), {'bot': type('obj', (object,), {})()})()
|
||||
|
||||
sender = HybridMessageSender(app.bot, session)
|
||||
|
||||
try:
|
||||
success, method = await sender.send_message_with_retry(
|
||||
chat_id=chat_id,
|
||||
message_text=message_text,
|
||||
group_id=group_id,
|
||||
max_retries=Config.MAX_RETRIES
|
||||
)
|
||||
|
||||
if success:
|
||||
# Обновить статус в БД
|
||||
message_group_repo = MessageGroupRepository(session)
|
||||
await message_group_repo.mark_as_sent(message_id, group_id)
|
||||
|
||||
logger.info(f"✅ Задача отправки выполнена: сообщение {message_id} в группу {group_id} (способ: {method})")
|
||||
return {'status': 'success', 'method': method}
|
||||
else:
|
||||
logger.error(f"❌ Ошибка отправки сообщения {message_id} в группу {group_id}")
|
||||
return {'status': 'failed'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в задаче отправки: {e}")
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
return asyncio.run(_send())
|
||||
|
||||
|
||||
@shared_task(name='app.celery_tasks.parse_group_members_task')
|
||||
def parse_group_members_task(group_id: int, chat_id: str, limit: int = 1000):
|
||||
"""
|
||||
Задача для загрузки участников группы
|
||||
|
||||
Args:
|
||||
group_id: ID группы в БД
|
||||
chat_id: ID чата в Telegram
|
||||
limit: Максимум участников для загрузки
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
async def _parse():
|
||||
if Config.USE_TELETHON and not telethon_manager.is_connected():
|
||||
await telethon_manager.initialize()
|
||||
|
||||
engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
member_repo = GroupMemberRepository(session)
|
||||
parser = GroupParser(session)
|
||||
|
||||
try:
|
||||
result = await parser.parse_group_members(
|
||||
chat_id=int(chat_id),
|
||||
member_repo=member_repo,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
logger.info(f"✅ Задача парсинга завершена: группа {group_id} - {result}")
|
||||
|
||||
# Коммитить изменения
|
||||
await session.commit()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в задаче парсинга: {e}")
|
||||
await session.rollback()
|
||||
return {'success': False, 'error': str(e)}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
return asyncio.run(_parse())
|
||||
|
||||
|
||||
@shared_task(name='app.celery_tasks.cleanup_old_messages_task')
|
||||
def cleanup_old_messages_task(days: int = 30):
|
||||
"""
|
||||
Задача для очистки старых сообщений из БД
|
||||
|
||||
Args:
|
||||
days: Удалить сообщения старше N дней
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
async def _cleanup():
|
||||
engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
message_repo = MessageRepository(session)
|
||||
|
||||
try:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
count = await message_repo.delete_before_date(cutoff_date)
|
||||
|
||||
logger.info(f"✅ Очистка завершена: удалено {count} сообщений старше {days} дней")
|
||||
|
||||
await session.commit()
|
||||
return {'status': 'success', 'deleted_count': count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в задаче очистки: {e}")
|
||||
await session.rollback()
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
return asyncio.run(_cleanup())
|
||||
|
||||
|
||||
@shared_task(name='app.celery_tasks.broadcast_message_task')
|
||||
def broadcast_message_task(message_id: int, group_ids: list):
|
||||
"""
|
||||
Задача для рассылки сообщения в несколько групп
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения в БД
|
||||
group_ids: Список ID групп в БД
|
||||
"""
|
||||
import asyncio
|
||||
from app.handlers.hybrid_sender import HybridMessageSender
|
||||
|
||||
async def _broadcast():
|
||||
if Config.USE_TELETHON and not telethon_manager.is_connected():
|
||||
await telethon_manager.initialize()
|
||||
|
||||
engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
message_repo = MessageRepository(session)
|
||||
group_repo = GroupRepository(session)
|
||||
message_group_repo = MessageGroupRepository(session)
|
||||
|
||||
try:
|
||||
# Получить сообщение
|
||||
message = await message_repo.get_by_id(message_id)
|
||||
if not message:
|
||||
logger.error(f"Сообщение {message_id} не найдено")
|
||||
return {'status': 'error', 'error': 'Message not found'}
|
||||
|
||||
# Получить группы
|
||||
groups = []
|
||||
for gid in group_ids:
|
||||
group = await group_repo.get_by_id(gid)
|
||||
if group:
|
||||
groups.append(group)
|
||||
|
||||
# Отправить во все группы
|
||||
app = type('obj', (object,), {'bot': type('obj', (object,), {})()})()
|
||||
sender = HybridMessageSender(app.bot, session)
|
||||
|
||||
results = {
|
||||
'total': len(groups),
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
'via_bot': 0,
|
||||
'via_client': 0
|
||||
}
|
||||
|
||||
for group in groups:
|
||||
success, method = await sender.send_message_with_retry(
|
||||
chat_id=group.chat_id,
|
||||
message_text=message.text,
|
||||
group_id=group.id,
|
||||
max_retries=Config.MAX_RETRIES
|
||||
)
|
||||
|
||||
if success:
|
||||
results['success'] += 1
|
||||
if method == 'bot':
|
||||
results['via_bot'] += 1
|
||||
else:
|
||||
results['via_client'] += 1
|
||||
await message_group_repo.mark_as_sent(message_id, group.id)
|
||||
else:
|
||||
results['failed'] += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"✅ Рассылка завершена: {results}")
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в задаче рассылки: {e}")
|
||||
await session.rollback()
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
return asyncio.run(_broadcast())
|
||||
|
||||
|
||||
# Периодические задачи
|
||||
@shared_task(name='app.celery_tasks.health_check_task')
|
||||
def health_check_task():
|
||||
"""Проверка здоровья системы"""
|
||||
logger.info("✅ Health check выполнен")
|
||||
return {'status': 'healthy'}
|
||||
63
app/config.py
Normal file
63
app/config.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Конфигурация логирования для бота
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Создаем директорию для логов если её нет
|
||||
LOGS_DIR = "logs"
|
||||
if not os.path.exists(LOGS_DIR):
|
||||
os.makedirs(LOGS_DIR)
|
||||
|
||||
# Формат логов
|
||||
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
LOG_DATEFORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Уровень логирования (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
|
||||
def setup_logging():
|
||||
"""Настройка логирования для всего приложения"""
|
||||
|
||||
# Корневой logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, LOG_LEVEL))
|
||||
|
||||
# Формат
|
||||
formatter = logging.Formatter(LOG_FORMAT, datefmt=LOG_DATEFORMAT)
|
||||
|
||||
# Handler для консоли
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(getattr(logging, LOG_LEVEL))
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Handler для файла (ротация по дням)
|
||||
log_file = os.path.join(LOGS_DIR, f'bot_{datetime.now().strftime("%Y-%m-%d")}.log')
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10 MB
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG) # Файл логирует всё
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Дополнительные логи для telegram
|
||||
logging.getLogger('telegram').setLevel(logging.WARNING)
|
||||
|
||||
return root_logger
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Пример использования
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.debug("Это debug сообщение")
|
||||
logger.info("Это info сообщение")
|
||||
logger.warning("Это warning сообщение")
|
||||
logger.error("Это error сообщение")
|
||||
33
app/database/__init__.py
Normal file
33
app/database/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import pool
|
||||
from app.models import Base
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'sqlite+aiosqlite:///./autoposter.db'
|
||||
)
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
poolclass=pool.NullPool
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация БД - создание всех таблиц"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session():
|
||||
"""Получить сессию БД"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
258
app/database/member_repository.py
Normal file
258
app/database/member_repository.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from ..models.group_members import GroupMember, GroupKeyword, GroupStatistics
|
||||
|
||||
|
||||
class GroupMemberRepository:
|
||||
"""Репозиторий для работы с участниками групп"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def add_member(self, group_id: int, user_id: str, username: str = None,
|
||||
first_name: str = None, last_name: str = None,
|
||||
is_bot: bool = False, is_admin: bool = False,
|
||||
is_owner: bool = False) -> GroupMember:
|
||||
"""Добавить участника в группу"""
|
||||
member = GroupMember(
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_bot=is_bot,
|
||||
is_admin=is_admin,
|
||||
is_owner=is_owner,
|
||||
joined_at=datetime.utcnow()
|
||||
)
|
||||
self.session.add(member)
|
||||
await self.session.flush()
|
||||
return member
|
||||
|
||||
async def get_member_by_user_id(self, group_id: int, user_id: str) -> Optional[GroupMember]:
|
||||
"""Получить участника по user_id"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(
|
||||
and_(
|
||||
GroupMember.group_id == group_id,
|
||||
GroupMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_members_by_group(self, group_id: int, is_admin: bool = None) -> List[GroupMember]:
|
||||
"""Получить всех участников группы"""
|
||||
query = select(GroupMember).where(GroupMember.group_id == group_id)
|
||||
if is_admin is not None:
|
||||
query = query.where(GroupMember.is_admin == is_admin)
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_member(self, group_id: int, user_id: str, **kwargs) -> Optional[GroupMember]:
|
||||
"""Обновить данные участника"""
|
||||
member = await self.get_member_by_user_id(group_id, user_id)
|
||||
if not member:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(member, key):
|
||||
setattr(member, key, value)
|
||||
|
||||
member.updated_at = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
return member
|
||||
|
||||
async def delete_member(self, group_id: int, user_id: str) -> bool:
|
||||
"""Удалить участника из группы"""
|
||||
member = await self.get_member_by_user_id(group_id, user_id)
|
||||
if not member:
|
||||
return False
|
||||
await self.session.delete(member)
|
||||
return True
|
||||
|
||||
async def bulk_add_members(self, group_id: int, members_data: List[dict]) -> List[GroupMember]:
|
||||
"""Массовое добавление участников"""
|
||||
members = []
|
||||
for data in members_data:
|
||||
member = GroupMember(
|
||||
group_id=group_id,
|
||||
user_id=data.get('user_id'),
|
||||
username=data.get('username'),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
is_bot=data.get('is_bot', False),
|
||||
is_admin=data.get('is_admin', False),
|
||||
is_owner=data.get('is_owner', False),
|
||||
joined_at=datetime.utcnow()
|
||||
)
|
||||
members.append(member)
|
||||
self.session.add_all(members)
|
||||
await self.session.flush()
|
||||
return members
|
||||
|
||||
async def search_members_by_username(self, group_id: int, keyword: str) -> List[GroupMember]:
|
||||
"""Поиск участников по username"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(
|
||||
and_(
|
||||
GroupMember.group_id == group_id,
|
||||
GroupMember.username.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def search_members_by_name(self, group_id: int, keyword: str) -> List[GroupMember]:
|
||||
"""Поиск участников по имени"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(
|
||||
and_(
|
||||
GroupMember.group_id == group_id,
|
||||
(GroupMember.first_name.ilike(f'%{keyword}%')) |
|
||||
(GroupMember.last_name.ilike(f'%{keyword}%'))
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_admin_count(self, group_id: int) -> int:
|
||||
"""Получить количество администраторов"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(
|
||||
and_(
|
||||
GroupMember.group_id == group_id,
|
||||
GroupMember.is_admin == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return len(result.scalars().all())
|
||||
|
||||
async def get_bot_count(self, group_id: int) -> int:
|
||||
"""Получить количество ботов"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(
|
||||
and_(
|
||||
GroupMember.group_id == group_id,
|
||||
GroupMember.is_bot == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return len(result.scalars().all())
|
||||
|
||||
async def clear_members(self, group_id: int) -> int:
|
||||
"""Очистить всех участников группы"""
|
||||
result = await self.session.execute(
|
||||
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||
)
|
||||
members = result.scalars().all()
|
||||
count = len(members)
|
||||
for member in members:
|
||||
await self.session.delete(member)
|
||||
return count
|
||||
|
||||
|
||||
class GroupKeywordRepository:
|
||||
"""Репозиторий для работы с ключевыми словами"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def add_keywords(self, group_id: int, keywords: str, description: str = None) -> GroupKeyword:
|
||||
"""Добавить ключевые слова для группы"""
|
||||
keyword = GroupKeyword(
|
||||
group_id=group_id,
|
||||
keywords=keywords,
|
||||
description=description
|
||||
)
|
||||
self.session.add(keyword)
|
||||
await self.session.flush()
|
||||
return keyword
|
||||
|
||||
async def get_keywords(self, group_id: int) -> Optional[GroupKeyword]:
|
||||
"""Получить ключевые слова для группы"""
|
||||
result = await self.session.execute(
|
||||
select(GroupKeyword).where(GroupKeyword.group_id == group_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_keywords(self, group_id: int, keywords: str, description: str = None) -> Optional[GroupKeyword]:
|
||||
"""Обновить ключевые слова"""
|
||||
kw = await self.get_keywords(group_id)
|
||||
if not kw:
|
||||
return None
|
||||
kw.keywords = keywords
|
||||
if description:
|
||||
kw.description = description
|
||||
kw.updated_at = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
return kw
|
||||
|
||||
async def delete_keywords(self, group_id: int) -> bool:
|
||||
"""Удалить ключевые слова"""
|
||||
kw = await self.get_keywords(group_id)
|
||||
if not kw:
|
||||
return False
|
||||
await self.session.delete(kw)
|
||||
return True
|
||||
|
||||
|
||||
class GroupStatisticsRepository:
|
||||
"""Репозиторий для работы со статистикой групп"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_or_create_statistics(self, group_id: int) -> GroupStatistics:
|
||||
"""Получить или создать статистику"""
|
||||
result = await self.session.execute(
|
||||
select(GroupStatistics).where(GroupStatistics.group_id == group_id)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
if not stats:
|
||||
stats = GroupStatistics(group_id=group_id)
|
||||
self.session.add(stats)
|
||||
await self.session.flush()
|
||||
return stats
|
||||
|
||||
async def update_members_count(self, group_id: int, total: int, admins: int = 0, bots: int = 0):
|
||||
"""Обновить количество участников"""
|
||||
stats = await self.get_or_create_statistics(group_id)
|
||||
stats.total_members = total
|
||||
stats.total_admins = admins
|
||||
stats.total_bots = bots
|
||||
stats.last_updated = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def increment_sent_messages(self, group_id: int, via_client: bool = False):
|
||||
"""Увеличить счетчик отправленных сообщений"""
|
||||
stats = await self.get_or_create_statistics(group_id)
|
||||
stats.messages_sent += 1
|
||||
if via_client:
|
||||
stats.messages_via_client += 1
|
||||
stats.last_updated = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def increment_failed_messages(self, group_id: int):
|
||||
"""Увеличить счетчик ошибок"""
|
||||
stats = await self.get_or_create_statistics(group_id)
|
||||
stats.messages_failed += 1
|
||||
stats.last_updated = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def update_send_capabilities(self, group_id: int, can_bot: bool, can_client: bool):
|
||||
"""Обновить возможности отправки"""
|
||||
stats = await self.get_or_create_statistics(group_id)
|
||||
stats.can_send_as_bot = can_bot
|
||||
stats.can_send_as_client = can_client
|
||||
stats.last_updated = datetime.utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def get_statistics(self, group_id: int) -> Optional[GroupStatistics]:
|
||||
"""Получить статистику"""
|
||||
result = await self.session.execute(
|
||||
select(GroupStatistics).where(GroupStatistics.group_id == group_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
205
app/database/repository.py
Normal file
205
app/database/repository.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models import Group, Message, MessageGroup
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GroupRepository:
|
||||
"""Репозиторий для работы с группами"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def add_group(self, chat_id: str, title: str, slow_mode_delay: int = 0) -> Group:
|
||||
"""Добавить новую группу"""
|
||||
group = Group(
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
slow_mode_delay=slow_mode_delay
|
||||
)
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(group)
|
||||
return group
|
||||
|
||||
async def get_group_by_chat_id(self, chat_id: str) -> Optional[Group]:
|
||||
"""Получить группу по ID чата"""
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.chat_id == chat_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all_active_groups(self) -> List[Group]:
|
||||
"""Получить все активные группы"""
|
||||
result = await self.session.execute(
|
||||
select(Group).where(Group.is_active == True)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_group_slow_mode(self, group_id: int, delay: int) -> None:
|
||||
"""Обновить slow mode задержку группы"""
|
||||
group = await self.session.get(Group, group_id)
|
||||
if group:
|
||||
group.slow_mode_delay = delay
|
||||
group.updated_at = datetime.utcnow()
|
||||
await self.session.commit()
|
||||
|
||||
async def update_last_message_time(self, group_id: int) -> None:
|
||||
"""Обновить время последнего сообщения"""
|
||||
group = await self.session.get(Group, group_id)
|
||||
if group:
|
||||
group.last_message_time = datetime.utcnow()
|
||||
await self.session.commit()
|
||||
|
||||
async def deactivate_group(self, group_id: int) -> None:
|
||||
"""Деактивировать группу"""
|
||||
group = await self.session.get(Group, group_id)
|
||||
if group:
|
||||
group.is_active = False
|
||||
await self.session.commit()
|
||||
|
||||
async def activate_group(self, group_id: int) -> None:
|
||||
"""Активировать группу"""
|
||||
group = await self.session.get(Group, group_id)
|
||||
if group:
|
||||
group.is_active = True
|
||||
await self.session.commit()
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
"""Репозиторий для работы с сообщениями"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def add_message(self, text: str, title: str, parse_mode: str = 'HTML') -> Message:
|
||||
"""Добавить новое сообщение"""
|
||||
message = Message(
|
||||
text=text,
|
||||
title=title,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
self.session.add(message)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(message)
|
||||
return message
|
||||
|
||||
async def get_message(self, message_id: int) -> Optional[Message]:
|
||||
"""Получить сообщение по ID"""
|
||||
result = await self.session.execute(
|
||||
select(Message).where(Message.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all_messages(self, active_only: bool = True) -> List[Message]:
|
||||
"""Получить все сообщения"""
|
||||
query = select(Message)
|
||||
if active_only:
|
||||
query = query.where(Message.is_active == True)
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_message(self, message_id: int, text: str = None, title: str = None) -> None:
|
||||
"""Обновить сообщение"""
|
||||
message = await self.session.get(Message, message_id)
|
||||
if message:
|
||||
if text:
|
||||
message.text = text
|
||||
if title:
|
||||
message.title = title
|
||||
message.updated_at = datetime.utcnow()
|
||||
await self.session.commit()
|
||||
|
||||
async def deactivate_message(self, message_id: int) -> None:
|
||||
"""Деактивировать сообщение"""
|
||||
message = await self.session.get(Message, message_id)
|
||||
if message:
|
||||
message.is_active = False
|
||||
await self.session.commit()
|
||||
|
||||
async def delete_message(self, message_id: int) -> None:
|
||||
"""Удалить сообщение"""
|
||||
message = await self.session.get(Message, message_id)
|
||||
if message:
|
||||
await self.session.delete(message)
|
||||
await self.session.commit()
|
||||
|
||||
|
||||
class MessageGroupRepository:
|
||||
"""Репозиторий для работы со связями сообщение-группа"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def add_message_to_group(self, message_id: int, group_id: int) -> MessageGroup:
|
||||
"""Добавить сообщение в группу"""
|
||||
# Проверить, не существует ли уже
|
||||
result = await self.session.execute(
|
||||
select(MessageGroup).where(
|
||||
(MessageGroup.message_id == message_id) &
|
||||
(MessageGroup.group_id == group_id)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
link = MessageGroup(message_id=message_id, group_id=group_id)
|
||||
self.session.add(link)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(link)
|
||||
return link
|
||||
|
||||
async def get_message_groups_to_send(self, message_id: int) -> List[MessageGroup]:
|
||||
"""Получить группы, куда еще не отправлено сообщение"""
|
||||
result = await self.session.execute(
|
||||
select(MessageGroup)
|
||||
.where((MessageGroup.message_id == message_id) & (MessageGroup.is_sent == False))
|
||||
.options(selectinload(MessageGroup.group))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_unsent_messages_for_group(self, group_id: int) -> List[MessageGroup]:
|
||||
"""Получить неотправленные сообщения для группы"""
|
||||
result = await self.session.execute(
|
||||
select(MessageGroup)
|
||||
.where((MessageGroup.group_id == group_id) & (MessageGroup.is_sent == False))
|
||||
.options(selectinload(MessageGroup.message))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def mark_as_sent(self, message_group_id: int, error: str = None) -> None:
|
||||
"""Отметить как отправленное"""
|
||||
link = await self.session.get(MessageGroup, message_group_id)
|
||||
if link:
|
||||
link.is_sent = True
|
||||
link.sent_at = datetime.utcnow()
|
||||
if error:
|
||||
link.error = error
|
||||
link.is_sent = False
|
||||
await self.session.commit()
|
||||
|
||||
async def get_messages_for_group(self, group_id: int) -> List[MessageGroup]:
|
||||
"""Получить все сообщения для группы с их статусом"""
|
||||
result = await self.session.execute(
|
||||
select(MessageGroup)
|
||||
.where(MessageGroup.group_id == group_id)
|
||||
.options(selectinload(MessageGroup.message))
|
||||
.order_by(MessageGroup.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def remove_message_from_group(self, message_id: int, group_id: int) -> None:
|
||||
"""Удалить сообщение из группы"""
|
||||
result = await self.session.execute(
|
||||
select(MessageGroup).where(
|
||||
(MessageGroup.message_id == message_id) &
|
||||
(MessageGroup.group_id == group_id)
|
||||
)
|
||||
)
|
||||
link = result.scalar_one_or_none()
|
||||
if link:
|
||||
await self.session.delete(link)
|
||||
await self.session.commit()
|
||||
19
app/handlers/__init__.py
Normal file
19
app/handlers/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .commands import start, help_command
|
||||
from .callbacks import (
|
||||
start_callback, manage_messages, manage_groups,
|
||||
list_messages, list_groups
|
||||
)
|
||||
from .sender import send_message
|
||||
from .group_manager import my_chat_member
|
||||
|
||||
__all__ = [
|
||||
'start',
|
||||
'help_command',
|
||||
'start_callback',
|
||||
'manage_messages',
|
||||
'manage_groups',
|
||||
'list_messages',
|
||||
'list_groups',
|
||||
'send_message',
|
||||
'my_chat_member',
|
||||
]
|
||||
146
app/handlers/callbacks.py
Normal file
146
app/handlers/callbacks.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from telegram.ext import ContextTypes, ConversationHandler
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.database.repository import GroupRepository, MessageRepository, MessageGroupRepository
|
||||
from app.utils.keyboards import (
|
||||
get_main_keyboard, get_back_keyboard, get_message_actions_keyboard,
|
||||
get_group_actions_keyboard, CallbackType
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Состояния для ConversationHandler
|
||||
WAITING_MESSAGE_TEXT = 1
|
||||
WAITING_MESSAGE_TITLE = 2
|
||||
WAITING_GROUP_SELECTION = 3
|
||||
WAITING_FOR_GROUP = 4
|
||||
|
||||
|
||||
async def start_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Главное меню"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
text = """🤖 <b>Автопостер - Главное меню</b>
|
||||
|
||||
Выберите, что вы хотите делать:"""
|
||||
|
||||
await query.edit_message_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def manage_messages(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Меню управления сообщениями"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
text = """📨 <b>Управление сообщениями</b>
|
||||
|
||||
Выберите действие:"""
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("➕ Новое сообщение", callback_data=CallbackType.CREATE_MESSAGE)],
|
||||
[InlineKeyboardButton("📜 Список сообщений", callback_data=CallbackType.LIST_MESSAGES)],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MAIN_MENU)],
|
||||
]
|
||||
|
||||
await query.edit_message_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
|
||||
async def manage_groups(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Меню управления группами"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
text = """👥 <b>Управление группами</b>
|
||||
|
||||
Выберите действие:"""
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📜 Список групп", callback_data=CallbackType.LIST_GROUPS)],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MAIN_MENU)],
|
||||
]
|
||||
|
||||
await query.edit_message_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
|
||||
async def list_messages(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Список всех сообщений"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = MessageRepository(session)
|
||||
messages = await repo.get_all_messages()
|
||||
|
||||
if not messages:
|
||||
text = "📭 Нет сообщений"
|
||||
keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MANAGE_MESSAGES)]]
|
||||
else:
|
||||
text = "📨 <b>Ваши сообщения:</b>\n\n"
|
||||
keyboard = []
|
||||
|
||||
for msg in messages:
|
||||
status = "✅" if msg.is_active else "❌"
|
||||
text += f"{status} <b>{msg.title}</b> (ID: {msg.id})\n"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(f"📤 {msg.title}", callback_data=f"send_msg_{msg.id}"),
|
||||
InlineKeyboardButton("🗑️", callback_data=f"delete_msg_{msg.id}")
|
||||
])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MANAGE_MESSAGES)])
|
||||
|
||||
await query.edit_message_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
|
||||
async def list_groups(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Список всех групп"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = GroupRepository(session)
|
||||
groups = await repo.get_all_active_groups()
|
||||
|
||||
if not groups:
|
||||
text = "👥 Нет групп в базе данных\n\nДобавьте бота в группы - они автоматически появятся здесь."
|
||||
keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MANAGE_GROUPS)]]
|
||||
else:
|
||||
text = "👥 <b>Группы в базе данных:</b>\n\n"
|
||||
keyboard = []
|
||||
|
||||
for group in groups:
|
||||
status = "✅" if group.is_active else "❌"
|
||||
delay = f"⏱️ {group.slow_mode_delay}s" if group.slow_mode_delay > 0 else "🚀 нет"
|
||||
text += f"{status} <b>{group.title}</b>\n"
|
||||
text += f" ID: {group.chat_id}\n"
|
||||
text += f" {delay}\n\n"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(f"📝 {group.title}", callback_data=f"group_messages_{group.id}"),
|
||||
InlineKeyboardButton("🗑️", callback_data=f"delete_group_{group.id}")
|
||||
])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MANAGE_GROUPS)])
|
||||
|
||||
await query.edit_message_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
58
app/handlers/commands.py
Normal file
58
app/handlers/commands.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.database.repository import GroupRepository, MessageRepository, MessageGroupRepository
|
||||
from app.utils.keyboards import get_main_keyboard, get_groups_keyboard, get_messages_keyboard
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Обработчик команды /start"""
|
||||
user = update.effective_user
|
||||
|
||||
text = f"""👋 Привет, {user.first_name}!
|
||||
|
||||
Я бот для автоматической рассылки сообщений в группы.
|
||||
|
||||
Что я умею:
|
||||
• 📨 Создавать и управлять сообщениями
|
||||
• 👥 Добавлять группы и управлять ими
|
||||
• 📤 Отправлять сообщения со скоростью группы (slow mode)
|
||||
• 📊 Отслеживать статус отправки
|
||||
|
||||
Выберите действие:"""
|
||||
|
||||
await update.message.reply_text(
|
||||
text,
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Обработчик команды /help"""
|
||||
text = """📖 Справка по использованию:
|
||||
|
||||
<b>Основные команды:</b>
|
||||
/start - Главное меню
|
||||
/help - Эта справка
|
||||
|
||||
<b>Как работать с сообщениями:</b>
|
||||
1. Перейдите в раздел "Сообщения"
|
||||
2. Создайте новое сообщение
|
||||
3. Введите текст сообщения
|
||||
4. Выберите группы для отправки
|
||||
|
||||
<b>Как работать с группами:</b>
|
||||
1. Бот автоматически обнаружит группы при добавлении
|
||||
2. Для каждой группы можно настроить slow mode
|
||||
3. Вы сможете отправлять разные сообщения в разные группы
|
||||
|
||||
<b>Slow mode:</b>
|
||||
Это ограничение на скорость отправки сообщений в группу.
|
||||
Бот автоматически учитывает это при отправке.
|
||||
|
||||
Нажмите /start для возврата в главное меню."""
|
||||
|
||||
await update.message.reply_text(text, parse_mode='HTML')
|
||||
64
app/handlers/group_manager.py
Normal file
64
app/handlers/group_manager.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from telegram import Update, ChatMember
|
||||
from telegram.ext import ContextTypes
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.database.repository import GroupRepository
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def my_chat_member(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
Обработчик изменения статуса бота в группах
|
||||
Срабатывает когда бот добавлен или удален из группы
|
||||
"""
|
||||
my_chat_member_update = update.my_chat_member
|
||||
|
||||
if my_chat_member_update.new_chat_member.status == "member":
|
||||
# Бот был добавлен в группу
|
||||
chat = my_chat_member_update.chat
|
||||
logger.info(f"Бот добавлен в группу: {chat.title} (ID: {chat.id})")
|
||||
|
||||
# Получаем информацию о slow mode
|
||||
try:
|
||||
chat_full = await context.bot.get_chat(chat.id)
|
||||
slow_mode_delay = chat_full.slow_mode_delay or 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
group_repo = GroupRepository(session)
|
||||
existing = await group_repo.get_group_by_chat_id(str(chat.id))
|
||||
|
||||
if not existing:
|
||||
# Добавляем новую группу
|
||||
group = await group_repo.add_group(
|
||||
chat_id=str(chat.id),
|
||||
title=chat.title,
|
||||
slow_mode_delay=slow_mode_delay
|
||||
)
|
||||
logger.info(f"Группа добавлена в БД: {group}")
|
||||
|
||||
# Уведомляем администратора (если это приватный чат)
|
||||
# Этого функционала нет, т.к. нет ID администратора
|
||||
else:
|
||||
# Обновляем slow mode если он изменился
|
||||
if existing.slow_mode_delay != slow_mode_delay:
|
||||
await group_repo.update_group_slow_mode(
|
||||
existing.id,
|
||||
slow_mode_delay
|
||||
)
|
||||
logger.info(f"Slow mode обновлен для {existing.title}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке добавления в группу: {e}")
|
||||
|
||||
elif my_chat_member_update.new_chat_member.status == "left":
|
||||
# Бот был удален из группы
|
||||
chat = my_chat_member_update.chat
|
||||
logger.info(f"Бот удален из группы: {chat.title} (ID: {chat.id})")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
group_repo = GroupRepository(session)
|
||||
group = await group_repo.get_group_by_chat_id(str(chat.id))
|
||||
if group:
|
||||
await group_repo.deactivate_group(group.id)
|
||||
logger.info(f"Группа деактивирована: {group}")
|
||||
313
app/handlers/group_parser.py
Normal file
313
app/handlers/group_parser.py
Normal file
@@ -0,0 +1,313 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
from telegram.ext import ContextTypes
|
||||
from app.handlers.telethon_client import telethon_manager
|
||||
from app.database.member_repository import GroupKeywordRepository, GroupStatisticsRepository
|
||||
from app.database.repository import GroupRepository, MessageGroupRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupParser:
|
||||
"""Парсер для поиска и анализа групп по ключевым словам"""
|
||||
|
||||
def __init__(self, db_session, bot=None):
|
||||
self.db_session = db_session
|
||||
self.bot = bot
|
||||
self.keyword_repo = GroupKeywordRepository(db_session)
|
||||
self.stats_repo = GroupStatisticsRepository(db_session)
|
||||
self.group_repo = GroupRepository(db_session)
|
||||
|
||||
async def parse_group_by_keywords(self, keywords: List[str], chat_id: int) -> Dict:
|
||||
"""
|
||||
Проанализировать группу и проверить совпадение с ключевыми словами
|
||||
|
||||
Args:
|
||||
keywords: Список ключевых слов для поиска
|
||||
chat_id: ID группы в Telegram
|
||||
|
||||
Returns:
|
||||
dict: Результаты анализа группы
|
||||
"""
|
||||
|
||||
if not telethon_manager.is_connected():
|
||||
logger.warning("Telethon клиент не подключен, не могу получить информацию о группе")
|
||||
return {'matched': False, 'keywords_found': []}
|
||||
|
||||
try:
|
||||
chat_info = await telethon_manager.get_chat_info(chat_id)
|
||||
if not chat_info:
|
||||
return {'matched': False, 'keywords_found': []}
|
||||
|
||||
# Объединить название и описание для поиска
|
||||
search_text = f"{chat_info.get('title', '')} {chat_info.get('description', '')}".lower()
|
||||
|
||||
# Найти совпадения ключевых слов
|
||||
matched_keywords = []
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in search_text:
|
||||
matched_keywords.append(keyword)
|
||||
|
||||
result = {
|
||||
'matched': len(matched_keywords) > 0,
|
||||
'keywords_found': matched_keywords,
|
||||
'chat_info': chat_info,
|
||||
'match_count': len(matched_keywords),
|
||||
'total_keywords': len(keywords),
|
||||
'match_percentage': (len(matched_keywords) / len(keywords) * 100) if keywords else 0
|
||||
}
|
||||
|
||||
logger.info(f"✅ Анализ группы {chat_id}: найдено {len(matched_keywords)} совпадений из {len(keywords)}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при анализе группы {chat_id}: {e}")
|
||||
return {'matched': False, 'keywords_found': []}
|
||||
|
||||
async def extract_keywords_from_text(self, text: str) -> List[str]:
|
||||
"""
|
||||
Извлечь ключевые слова из текста
|
||||
|
||||
Args:
|
||||
text: Текст для извлечения ключевых слов
|
||||
|
||||
Returns:
|
||||
List[str]: Список ключевых слов
|
||||
"""
|
||||
|
||||
# Удалить спецсимволы и разбить на слова
|
||||
words = re.findall(r'\b\w+\b', text.lower())
|
||||
|
||||
# Отфильтровать стоп-слова
|
||||
stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
||||
'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
||||
'я', 'ты', 'он', 'она', 'оно', 'мы', 'вы', 'они',
|
||||
'и', 'или', 'но', 'в', 'на', 'к', 'по', 'с', 'о', 'об',
|
||||
'что', 'как', 'где', 'когда', 'зачем', 'откуда', 'куда'}
|
||||
|
||||
keywords = [w for w in words if len(w) > 3 and w not in stop_words]
|
||||
|
||||
# Убрать дубликаты
|
||||
return list(set(keywords))
|
||||
|
||||
async def parse_group_members(self, chat_id: int, member_repo,
|
||||
limit: int = 100) -> Dict:
|
||||
"""
|
||||
Получить и сохранить список участников группы
|
||||
|
||||
Args:
|
||||
chat_id: ID группы
|
||||
member_repo: Репозиторий участников
|
||||
limit: Максимум участников для загрузки
|
||||
|
||||
Returns:
|
||||
dict: Статистика загруженных участников
|
||||
"""
|
||||
|
||||
if not telethon_manager.is_connected():
|
||||
logger.warning("Telethon клиент не подключен, не могу получить участников")
|
||||
return {'success': False, 'members_added': 0}
|
||||
|
||||
try:
|
||||
# Получить группу из БД
|
||||
db_group = await self.group_repo.get_by_chat_id(str(chat_id))
|
||||
if not db_group:
|
||||
logger.warning(f"Группа {chat_id} не найдена в БД")
|
||||
return {'success': False, 'members_added': 0}
|
||||
|
||||
# Получить участников
|
||||
members = await telethon_manager.get_chat_members(chat_id, limit)
|
||||
if not members:
|
||||
return {'success': True, 'members_added': 0}
|
||||
|
||||
# Сохранить в БД
|
||||
members_data = members # Уже в нужном формате из telethon_manager
|
||||
|
||||
# Очистить старых участников и добавить новых
|
||||
await member_repo.clear_members(db_group.id)
|
||||
added = await member_repo.bulk_add_members(db_group.id, members_data)
|
||||
|
||||
# Обновить статистику
|
||||
admins = len([m for m in members_data if m.get('is_admin')])
|
||||
bots = len([m for m in members_data if m.get('is_bot')])
|
||||
|
||||
await self.stats_repo.update_members_count(
|
||||
db_group.id,
|
||||
total=len(members_data),
|
||||
admins=admins,
|
||||
bots=bots
|
||||
)
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'members_added': len(added),
|
||||
'admins_count': admins,
|
||||
'bots_count': bots,
|
||||
'users_count': len(members_data) - bots
|
||||
}
|
||||
|
||||
logger.info(f"✅ Загружены участники группы {chat_id}: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при загрузке участников группы {chat_id}: {e}")
|
||||
return {'success': False, 'members_added': 0}
|
||||
|
||||
async def search_groups_by_keywords(self, keywords: List[str],
|
||||
group_ids: List[int] = None) -> Dict:
|
||||
"""
|
||||
Искать группы по ключевым словам из списка
|
||||
|
||||
Args:
|
||||
keywords: Список ключевых слов для поиска
|
||||
group_ids: Список ID групп для проверки (если None - проверить все)
|
||||
|
||||
Returns:
|
||||
dict: Результаты поиска
|
||||
"""
|
||||
|
||||
if not group_ids:
|
||||
# Получить все активные группы
|
||||
all_groups = await self.group_repo.get_active_groups()
|
||||
group_ids = [g.id for g in all_groups]
|
||||
|
||||
results = {
|
||||
'total_checked': len(group_ids),
|
||||
'matched_groups': [],
|
||||
'no_match': [],
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for group_id in group_ids:
|
||||
try:
|
||||
# Получить группу
|
||||
db_group = await self.group_repo.get_by_id(group_id)
|
||||
if not db_group:
|
||||
results['errors'].append({'group_id': group_id, 'error': 'Not found in DB'})
|
||||
continue
|
||||
|
||||
# Анализировать
|
||||
match_result = await self.parse_group_by_keywords(keywords, int(db_group.chat_id))
|
||||
|
||||
if match_result['matched']:
|
||||
results['matched_groups'].append({
|
||||
'group_id': group_id,
|
||||
'chat_id': db_group.chat_id,
|
||||
'title': db_group.title,
|
||||
'keywords_found': match_result['keywords_found'],
|
||||
'match_percentage': match_result['match_percentage']
|
||||
})
|
||||
else:
|
||||
results['no_match'].append({
|
||||
'group_id': group_id,
|
||||
'chat_id': db_group.chat_id,
|
||||
'title': db_group.title
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке группы {group_id}: {e}")
|
||||
results['errors'].append({'group_id': group_id, 'error': str(e)})
|
||||
|
||||
logger.info(f"Поиск по ключевым словам завершен: найдено {len(results['matched_groups'])} групп")
|
||||
return results
|
||||
|
||||
async def set_group_keywords(self, group_id: int, keywords: List[str],
|
||||
description: str = None) -> bool:
|
||||
"""
|
||||
Установить ключевые слова для группы
|
||||
|
||||
Args:
|
||||
group_id: ID группы в БД
|
||||
keywords: Список ключевых слов
|
||||
description: Описание для поиска
|
||||
|
||||
Returns:
|
||||
bool: Успешность операции
|
||||
"""
|
||||
|
||||
try:
|
||||
# Сериализовать список в JSON
|
||||
keywords_json = json.dumps(keywords)
|
||||
|
||||
# Проверить наличие записи
|
||||
existing = await self.keyword_repo.get_keywords(group_id)
|
||||
if existing:
|
||||
await self.keyword_repo.update_keywords(group_id, keywords_json, description)
|
||||
else:
|
||||
await self.keyword_repo.add_keywords(group_id, keywords_json, description)
|
||||
|
||||
logger.info(f"Ключевые слова установлены для группы {group_id}: {keywords}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при установке ключевых слов: {e}")
|
||||
return False
|
||||
|
||||
async def get_group_keywords(self, group_id: int) -> Optional[List[str]]:
|
||||
"""
|
||||
Получить ключевые слова для группы
|
||||
|
||||
Args:
|
||||
group_id: ID группы в БД
|
||||
|
||||
Returns:
|
||||
List[str]: Список ключевых слов или None
|
||||
"""
|
||||
|
||||
try:
|
||||
keyword_obj = await self.keyword_repo.get_keywords(group_id)
|
||||
if not keyword_obj:
|
||||
return None
|
||||
|
||||
return json.loads(keyword_obj.keywords)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключевых слов: {e}")
|
||||
return None
|
||||
|
||||
async def format_group_info(self, group_id: int) -> str:
|
||||
"""
|
||||
Форматировать информацию о группе для вывода
|
||||
|
||||
Args:
|
||||
group_id: ID группы в БД
|
||||
|
||||
Returns:
|
||||
str: Отформатированная информация
|
||||
"""
|
||||
|
||||
try:
|
||||
group = await self.group_repo.get_by_id(group_id)
|
||||
if not group:
|
||||
return "Группа не найдена"
|
||||
|
||||
stats = await self.stats_repo.get_statistics(group_id)
|
||||
keywords = await self.get_group_keywords(group_id)
|
||||
|
||||
info = f"<b>Группа:</b> {group.title}\n"
|
||||
info += f"<b>Chat ID:</b> <code>{group.chat_id}</code>\n"
|
||||
info += f"<b>Активна:</b> {'✅ Да' if group.is_active else '❌ Нет'}\n"
|
||||
|
||||
if stats:
|
||||
info += f"\n<b>Статистика:</b>\n"
|
||||
info += f" Участников: {stats.total_members}\n"
|
||||
info += f" Администраторов: {stats.total_admins}\n"
|
||||
info += f" Ботов: {stats.total_bots}\n"
|
||||
info += f" Отправлено: {stats.messages_sent}\n"
|
||||
info += f" Через клиент: {stats.messages_via_client}\n"
|
||||
info += f" Может отправлять как бот: {'✅' if stats.can_send_as_bot else '❌'}\n"
|
||||
info += f" Может отправлять как клиент: {'✅' if stats.can_send_as_client else '❌'}\n"
|
||||
|
||||
if keywords:
|
||||
info += f"\n<b>Ключевые слова:</b>\n"
|
||||
for kw in keywords:
|
||||
info += f" • {kw}\n"
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при форматировании информации: {e}")
|
||||
return "Ошибка при получении информации"
|
||||
248
app/handlers/hybrid_sender.py
Normal file
248
app/handlers/hybrid_sender.py
Normal file
@@ -0,0 +1,248 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Tuple
|
||||
from telegram.error import TelegramError, BadRequest, Forbidden
|
||||
from telethon.errors import FloodWaitError, UserDeactivatedError, ChatAdminRequiredError
|
||||
|
||||
from app.handlers.telethon_client import telethon_manager
|
||||
from app.handlers.sender import MessageSender
|
||||
from app.database.member_repository import GroupStatisticsRepository
|
||||
from app.settings import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HybridMessageSender:
|
||||
"""
|
||||
Гибридный отправитель сообщений.
|
||||
Пытается отправить как бот, при ошибке переключается на Pyrogram клиента.
|
||||
"""
|
||||
|
||||
def __init__(self, bot, db_session):
|
||||
self.bot = bot
|
||||
self.db_session = db_session
|
||||
self.message_sender = MessageSender(bot, db_session)
|
||||
self.stats_repo = GroupStatisticsRepository(db_session)
|
||||
|
||||
async def send_message(self, chat_id: str, message_text: str,
|
||||
group_id: int = None,
|
||||
parse_mode: str = "HTML",
|
||||
disable_web_page_preview: bool = True) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Отправить сообщение с гибридной логикой.
|
||||
|
||||
Сначала пытается отправить как бот, если ошибка - переходит на клиент.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (успешность, метод_отправки)
|
||||
Методы: 'bot', 'client', None если оба способа не работают
|
||||
"""
|
||||
|
||||
# Попытка 1: отправить как бот
|
||||
try:
|
||||
logger.info(f"Попытка отправить сообщение как бот в {chat_id}")
|
||||
await self.message_sender.send_message(
|
||||
chat_id=chat_id,
|
||||
message_text=message_text,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_web_page_preview
|
||||
)
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.update_send_capabilities(group_id, can_bot=True, can_client=False)
|
||||
logger.info(f"Сообщение успешно отправлено ботом в {chat_id}")
|
||||
return True, "bot"
|
||||
|
||||
except (BadRequest, Forbidden) as e:
|
||||
# Ошибки которые означают что бот не может писать
|
||||
logger.warning(f"Бот не может отправить сообщение в {chat_id}: {e}")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.update_send_capabilities(group_id, can_bot=False, can_client=False)
|
||||
|
||||
# Если Telethon отключен или не инициализирован - выходим
|
||||
if not Config.USE_TELETHON or not telethon_manager.is_connected():
|
||||
logger.error(f"Telethon недоступен, не удалось отправить сообщение в {chat_id}")
|
||||
return False, None
|
||||
|
||||
# Попытка 2: отправить как клиент
|
||||
return await self._send_via_telethon(chat_id, message_text, group_id)
|
||||
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка Telegram при отправке в {chat_id}: {e}")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке в {chat_id}: {e}")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
return False, None
|
||||
|
||||
async def _send_via_telethon(self, chat_id: str, message_text: str,
|
||||
group_id: int = None) -> Tuple[bool, Optional[str]]:
|
||||
"""Отправить сообщение через Telethon клиент"""
|
||||
|
||||
if not telethon_manager.is_connected():
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return False, None
|
||||
|
||||
try:
|
||||
# Конвертировать chat_id в int для Telethon
|
||||
try:
|
||||
numeric_chat_id = int(chat_id)
|
||||
except ValueError:
|
||||
# Если это строка типа "-100123456789"
|
||||
numeric_chat_id = int(chat_id)
|
||||
|
||||
logger.info(f"Попытка отправить сообщение через Telethon в {numeric_chat_id}")
|
||||
|
||||
message_id = await telethon_manager.send_message(
|
||||
chat_id=numeric_chat_id,
|
||||
text=message_text,
|
||||
parse_mode="html",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
if message_id:
|
||||
if group_id:
|
||||
await self.stats_repo.increment_sent_messages(group_id, via_client=True)
|
||||
await self.stats_repo.update_send_capabilities(group_id, can_bot=False, can_client=True)
|
||||
|
||||
logger.info(f"✅ Сообщение успешно отправлено через Telethon в {numeric_chat_id}")
|
||||
return True, "client"
|
||||
else:
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
return False, None
|
||||
|
||||
except FloodWaitError as e:
|
||||
logger.warning(f"⏳ FloodWait от Telethon: нужно ждать {e.seconds} сек")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
# Ожидание и повторная попытка
|
||||
await asyncio.sleep(min(e.seconds, Config.TELETHON_FLOOD_WAIT_MAX))
|
||||
return await self._send_via_telethon(chat_id, message_text, group_id)
|
||||
|
||||
except (ChatAdminRequiredError, UserDeactivatedError):
|
||||
logger.error(f"❌ Telethon клиент не администратор в {chat_id}")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.update_send_capabilities(group_id, can_bot=False, can_client=False)
|
||||
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка Telethon при отправке в {chat_id}: {e}")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
return False, None
|
||||
|
||||
async def send_message_with_retry(self, chat_id: str, message_text: str,
|
||||
group_id: int = None,
|
||||
max_retries: int = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Отправить сообщение с повторными попытками
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
message_text: Текст сообщения
|
||||
group_id: ID группы в БД (для отслеживания статистики)
|
||||
max_retries: Максимум повторов (по умолчанию из Config)
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (успешность, метод_отправки)
|
||||
"""
|
||||
|
||||
if max_retries is None:
|
||||
max_retries = Config.MAX_RETRIES
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
success, method = await self.send_message(
|
||||
chat_id=chat_id,
|
||||
message_text=message_text,
|
||||
group_id=group_id,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if success:
|
||||
return True, method
|
||||
|
||||
# Ждать перед повторной попыткой
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = Config.RETRY_DELAY * (attempt + 1)
|
||||
logger.info(f"Повтор попытки {attempt + 1}/{max_retries} через {wait_time}с")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при попытке {attempt + 1}: {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(Config.RETRY_DELAY)
|
||||
|
||||
logger.error(f"Не удалось отправить сообщение в {chat_id} после {max_retries} попыток")
|
||||
|
||||
if group_id:
|
||||
await self.stats_repo.increment_failed_messages(group_id)
|
||||
|
||||
return False, None
|
||||
|
||||
async def bulk_send(self, chat_ids: list, message_text: str,
|
||||
group_ids: list = None,
|
||||
use_slow_mode: bool = False) -> dict:
|
||||
"""
|
||||
Массовая отправка сообщений
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'total': количество чатов,
|
||||
'success': успешно отправлено,
|
||||
'failed': ошибок,
|
||||
'via_bot': через бот,
|
||||
'via_client': через клиент
|
||||
}
|
||||
"""
|
||||
|
||||
results = {
|
||||
'total': len(chat_ids),
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
'via_bot': 0,
|
||||
'via_client': 0
|
||||
}
|
||||
|
||||
for idx, chat_id in enumerate(chat_ids):
|
||||
group_id = group_ids[idx] if group_ids else None
|
||||
|
||||
success, method = await self.send_message_with_retry(
|
||||
chat_id=str(chat_id),
|
||||
message_text=message_text,
|
||||
group_id=group_id
|
||||
)
|
||||
|
||||
if success:
|
||||
results['success'] += 1
|
||||
if method == 'bot':
|
||||
results['via_bot'] += 1
|
||||
elif method == 'client':
|
||||
results['via_client'] += 1
|
||||
else:
|
||||
results['failed'] += 1
|
||||
|
||||
# Slow mode
|
||||
if use_slow_mode and idx < len(chat_ids) - 1:
|
||||
await asyncio.sleep(Config.MIN_SEND_INTERVAL)
|
||||
|
||||
logger.info(f"Массовая отправка завершена: {results}")
|
||||
return results
|
||||
198
app/handlers/message_manager.py
Normal file
198
app/handlers/message_manager.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from telegram.ext import ContextTypes, ConversationHandler
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.database.repository import (
|
||||
GroupRepository, MessageRepository, MessageGroupRepository
|
||||
)
|
||||
from app.utils.keyboards import (
|
||||
get_back_keyboard, get_main_keyboard, CallbackType
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Состояния для ConversationHandler
|
||||
CREATE_MSG_TITLE = 1
|
||||
CREATE_MSG_TEXT = 2
|
||||
SELECT_GROUPS = 3
|
||||
WAITING_GROUP_INPUT = 4
|
||||
|
||||
|
||||
async def create_message_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Начало создания нового сообщения"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
text = "📝 Введите название сообщения (короткое описание):"
|
||||
|
||||
await query.edit_message_text(text)
|
||||
return CREATE_MSG_TITLE
|
||||
|
||||
|
||||
async def create_message_title(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Получаем название и просим текст"""
|
||||
message = update.message
|
||||
title = message.text.strip()
|
||||
|
||||
if len(title) > 100:
|
||||
await message.reply_text("❌ Название слишком длинное (макс 100 символов)")
|
||||
return CREATE_MSG_TITLE
|
||||
|
||||
context.user_data['message_title'] = title
|
||||
|
||||
text = """✏️ Теперь введите текст сообщения.
|
||||
|
||||
Вы можете использовать HTML форматирование:
|
||||
<b>жирный</b>
|
||||
<i>курсив</i>
|
||||
<u>подчеркивание</u>
|
||||
<code>код</code>
|
||||
|
||||
Введите /cancel для отмены"""
|
||||
|
||||
await message.reply_text(text, parse_mode='HTML')
|
||||
return CREATE_MSG_TEXT
|
||||
|
||||
|
||||
async def create_message_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Получаем текст и показываем выбор групп"""
|
||||
message = update.message
|
||||
|
||||
if message.text == '/cancel':
|
||||
await message.reply_text("❌ Отменено", reply_markup=get_main_keyboard())
|
||||
return ConversationHandler.END
|
||||
|
||||
text = message.text.strip()
|
||||
|
||||
if len(text) > 4096:
|
||||
await message.reply_text("❌ Текст слишком длинный (макс 4096 символов)")
|
||||
return CREATE_MSG_TEXT
|
||||
|
||||
context.user_data['message_text'] = text
|
||||
|
||||
# Сохраняем сообщение в БД
|
||||
async with AsyncSessionLocal() as session:
|
||||
msg_repo = MessageRepository(session)
|
||||
msg = await msg_repo.add_message(
|
||||
text=text,
|
||||
title=context.user_data['message_title']
|
||||
)
|
||||
context.user_data['message_id'] = msg.id
|
||||
|
||||
# Теперь показываем список групп для выбора
|
||||
async with AsyncSessionLocal() as session:
|
||||
group_repo = GroupRepository(session)
|
||||
groups = await group_repo.get_all_active_groups()
|
||||
|
||||
if not groups:
|
||||
await message.reply_text(
|
||||
"❌ Нет активных групп. Сначала добавьте бота в группы.",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
# Создаем клавиатуру с группами
|
||||
keyboard = []
|
||||
for group in groups:
|
||||
callback = f"select_group_{group.id}"
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"✅ {group.title} (delay: {group.slow_mode_delay}s)",
|
||||
callback_data=callback
|
||||
)])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("✔️ Готово", callback_data="done_groups")])
|
||||
keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data=f"{CallbackType.MAIN_MENU}")])
|
||||
|
||||
text = f"""✅ Сообщение создано: <b>{context.user_data['message_title']}</b>
|
||||
|
||||
Выберите группы для отправки (нажмите на каждую):"""
|
||||
|
||||
await message.reply_text(
|
||||
text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
context.user_data['selected_groups'] = []
|
||||
return SELECT_GROUPS
|
||||
|
||||
|
||||
async def select_groups(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Выбор групп для отправки"""
|
||||
query = update.callback_query
|
||||
callback_data = query.data
|
||||
|
||||
if callback_data == "done_groups":
|
||||
# Подтверждаем выбор
|
||||
selected = context.user_data.get('selected_groups', [])
|
||||
|
||||
if not selected:
|
||||
await query.answer("❌ Выберите хотя бы одну группу", show_alert=True)
|
||||
return SELECT_GROUPS
|
||||
|
||||
# Добавляем сообщение в выбранные группы
|
||||
message_id = context.user_data['message_id']
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
mg_repo = MessageGroupRepository(session)
|
||||
for group_id in selected:
|
||||
await mg_repo.add_message_to_group(message_id, group_id)
|
||||
|
||||
text = f"""✅ <b>Сообщение готово!</b>
|
||||
|
||||
Название: {context.user_data['message_title']}
|
||||
Групп выбрано: {len(selected)}
|
||||
|
||||
Теперь вы можете отправить сообщение нажав кнопку "Отправить" в списке сообщений."""
|
||||
|
||||
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_main_keyboard())
|
||||
return ConversationHandler.END
|
||||
|
||||
elif callback_data.startswith("select_group_"):
|
||||
group_id = int(callback_data.split("_")[2])
|
||||
selected = context.user_data.get('selected_groups', [])
|
||||
|
||||
if group_id in selected:
|
||||
selected.remove(group_id)
|
||||
else:
|
||||
selected.append(group_id)
|
||||
|
||||
context.user_data['selected_groups'] = selected
|
||||
|
||||
# Обновляем клавиатуру
|
||||
async with AsyncSessionLocal() as session:
|
||||
group_repo = GroupRepository(session)
|
||||
groups = await group_repo.get_all_active_groups()
|
||||
|
||||
keyboard = []
|
||||
for group in groups:
|
||||
callback = f"select_group_{group.id}"
|
||||
is_selected = group.id in selected
|
||||
prefix = "✅" if is_selected else "☐"
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"{prefix} {group.title} (delay: {group.slow_mode_delay}s)",
|
||||
callback_data=callback
|
||||
)])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("✔️ Готово", callback_data="done_groups")])
|
||||
keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data=f"{CallbackType.MAIN_MENU}")])
|
||||
|
||||
await query.edit_message_text(
|
||||
f"Выбрано групп: {len(selected)}",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
|
||||
await query.answer()
|
||||
return SELECT_GROUPS
|
||||
|
||||
elif callback_data == CallbackType.MAIN_MENU:
|
||||
# Отмена
|
||||
await query.answer()
|
||||
await query.edit_message_text(
|
||||
"❌ Создание сообщения отменено",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
return SELECT_GROUPS
|
||||
227
app/handlers/pyrogram_client.py
Normal file
227
app/handlers/pyrogram_client.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import logging
|
||||
from typing import List, Optional, Dict
|
||||
from pyrogram import Client
|
||||
from pyrogram.types import Message, ChatMember
|
||||
from pyrogram.errors import (
|
||||
FloodWait, UserDeactivated, ChatAdminRequired,
|
||||
PeerIdInvalid, ChannelInvalid, UserNotParticipant
|
||||
)
|
||||
from app.settings import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyrogramClientManager:
|
||||
"""Менеджер для работы с Pyrogram клиентом"""
|
||||
|
||||
def __init__(self):
|
||||
self.client: Optional[Client] = None
|
||||
self.is_initialized = False
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Инициализировать Pyrogram клиент"""
|
||||
try:
|
||||
if not Config.USE_PYROGRAM:
|
||||
logger.warning("Pyrogram отключен в конфигурации")
|
||||
return False
|
||||
|
||||
if not (Config.PYROGRAM_API_ID and Config.PYROGRAM_API_HASH):
|
||||
logger.error("PYROGRAM_API_ID или PYROGRAM_API_HASH не установлены")
|
||||
return False
|
||||
|
||||
self.client = Client(
|
||||
name="tg_autoposter",
|
||||
api_id=Config.PYROGRAM_API_ID,
|
||||
api_hash=Config.PYROGRAM_API_HASH,
|
||||
phone_number=Config.PYROGRAM_PHONE
|
||||
)
|
||||
|
||||
await self.client.start()
|
||||
self.is_initialized = True
|
||||
me = await self.client.get_me()
|
||||
logger.info(f"Pyrogram клиент инициализирован: {me.first_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при инициализации Pyrogram: {e}")
|
||||
return False
|
||||
|
||||
async def shutdown(self):
|
||||
"""Остановить Pyrogram клиент"""
|
||||
if self.client and self.is_initialized:
|
||||
try:
|
||||
await self.client.stop()
|
||||
self.is_initialized = False
|
||||
logger.info("Pyrogram клиент остановлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при остановке Pyrogram: {e}")
|
||||
|
||||
async def send_message(self, chat_id: int, text: str,
|
||||
parse_mode: str = "html",
|
||||
disable_web_page_preview: bool = True) -> Optional[Message]:
|
||||
"""Отправить сообщение в чат"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
message = await self.client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_web_page_preview
|
||||
)
|
||||
logger.info(f"Сообщение отправлено в чат {chat_id} (клиент)")
|
||||
return message
|
||||
|
||||
except FloodWait as e:
|
||||
logger.warning(f"FloodWait: нужно ждать {e.value} секунд")
|
||||
raise
|
||||
|
||||
except (ChatAdminRequired, UserNotParticipant):
|
||||
logger.error(f"Клиент не администратор или не участник чата {chat_id}")
|
||||
return None
|
||||
|
||||
except PeerIdInvalid:
|
||||
logger.error(f"Неверный ID чата: {chat_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
async def get_chat_members(self, chat_id: int, limit: int = None) -> List[ChatMember]:
|
||||
"""Получить список участников чата"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return []
|
||||
|
||||
try:
|
||||
members = []
|
||||
async for member in self.client.get_chat_members(chat_id):
|
||||
members.append(member)
|
||||
if limit and len(members) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"Получено {len(members)} участников из чата {chat_id}")
|
||||
return members
|
||||
|
||||
except (ChatAdminRequired, UserNotParticipant):
|
||||
logger.error(f"Нет прав получить участников чата {chat_id}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении участников: {e}")
|
||||
return []
|
||||
|
||||
async def get_chat_info(self, chat_id: int) -> Optional[Dict]:
|
||||
"""Получить информацию о чате"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
chat = await self.client.get_chat(chat_id)
|
||||
return {
|
||||
'id': chat.id,
|
||||
'title': chat.title,
|
||||
'description': getattr(chat, 'description', None),
|
||||
'members_count': getattr(chat, 'members_count', None),
|
||||
'is_supergroup': chat.is_supergroup,
|
||||
'linked_chat': getattr(chat, 'linked_chat_id', None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении информации о чате {chat_id}: {e}")
|
||||
return None
|
||||
|
||||
async def join_chat(self, chat_link: str) -> Optional[int]:
|
||||
"""Присоединиться к чату по ссылке"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
chat = await self.client.join_chat(chat_link)
|
||||
logger.info(f"Присоединился к чату: {chat.id}")
|
||||
return chat.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при присоединении к чату: {e}")
|
||||
return None
|
||||
|
||||
async def leave_chat(self, chat_id: int) -> bool:
|
||||
"""Покинуть чат"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.client.leave_chat(chat_id)
|
||||
logger.info(f"Покинул чат: {chat_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при выходе из чата: {e}")
|
||||
return False
|
||||
|
||||
async def edit_message(self, chat_id: int, message_id: int, text: str) -> Optional[Message]:
|
||||
"""Отредактировать сообщение"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
message = await self.client.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
parse_mode="html"
|
||||
)
|
||||
logger.info(f"Сообщение отредактировано: {chat_id}/{message_id}")
|
||||
return message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при редактировании сообщения: {e}")
|
||||
return None
|
||||
|
||||
async def delete_message(self, chat_id: int, message_id: int) -> bool:
|
||||
"""Удалить сообщение"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.client.delete_messages(chat_id, message_id)
|
||||
logger.info(f"Сообщение удалено: {chat_id}/{message_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
return False
|
||||
|
||||
async def search_messages(self, chat_id: int, query: str, limit: int = 100) -> List[Message]:
|
||||
"""Искать сообщения в чате"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Pyrogram клиент не инициализирован")
|
||||
return []
|
||||
|
||||
try:
|
||||
messages = []
|
||||
async for message in self.client.search_messages(chat_id, query=query, limit=limit):
|
||||
messages.append(message)
|
||||
|
||||
logger.info(f"Найдено {len(messages)} сообщений по запросу '{query}'")
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при поиске сообщений: {e}")
|
||||
return []
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Проверить, подключен ли клиент"""
|
||||
return self.is_initialized and self.client is not None
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера
|
||||
pyrogram_manager = PyrogramClientManager()
|
||||
139
app/handlers/schedule.py
Normal file
139
app/handlers/schedule.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Обработчик команд для управления расписанием рассылок
|
||||
"""
|
||||
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from app.scheduler import broadcast_scheduler, schedule_broadcast, cancel_broadcast, list_broadcasts
|
||||
from app.database.repository import MessageRepository
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Команда для управления расписанием"""
|
||||
|
||||
if not update.message:
|
||||
return
|
||||
|
||||
user_id = update.message.from_user.id
|
||||
|
||||
# Только администратор может управлять расписанием
|
||||
# (это нужно добавить в конфигурацию)
|
||||
|
||||
try:
|
||||
# /schedule list - показать все расписания
|
||||
if context.args and context.args[0] == 'list':
|
||||
schedules = await list_broadcasts()
|
||||
|
||||
if not schedules:
|
||||
await update.message.reply_text("📋 Нет активных расписаний")
|
||||
return
|
||||
|
||||
text = "📅 Активные расписания:\n\n"
|
||||
for idx, sched in enumerate(schedules, 1):
|
||||
text += f"{idx}. {sched['name']}\n"
|
||||
text += f" ID: `{sched['id']}`\n"
|
||||
text += f" Расписание: {sched['trigger']}\n"
|
||||
text += f" Следующее выполнение: {sched['next_run_time']}\n\n"
|
||||
|
||||
await update.message.reply_text(text, parse_mode='Markdown')
|
||||
|
||||
# /schedule add message_id group_id cron_expr
|
||||
elif context.args and context.args[0] == 'add':
|
||||
if len(context.args) < 4:
|
||||
await update.message.reply_text(
|
||||
"❌ Использование: /schedule add <message_id> <group_id> <cron_expr>\n\n"
|
||||
"Пример: /schedule add 1 10 '0 9 * * *'\n\n"
|
||||
"Cron формат: minute hour day month day_of_week\n"
|
||||
"0 9 * * * - ежедневно в 9:00 UTC"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
message_id = int(context.args[1])
|
||||
group_id = int(context.args[2])
|
||||
cron_expr = ' '.join(context.args[3:])
|
||||
|
||||
# Проверить, что сообщение существует
|
||||
async with AsyncSessionLocal() as session:
|
||||
message_repo = MessageRepository(session)
|
||||
message = await message_repo.get_by_id(message_id)
|
||||
if not message:
|
||||
await update.message.reply_text(f"❌ Сообщение с ID {message_id} не найдено")
|
||||
return
|
||||
|
||||
job_id = await schedule_broadcast(
|
||||
message_id=message_id,
|
||||
group_ids=[group_id],
|
||||
cron_expr=cron_expr
|
||||
)
|
||||
|
||||
await update.message.reply_text(
|
||||
f"✅ Расписание создано!\n\n"
|
||||
f"ID: `{job_id}`\n"
|
||||
f"Сообщение: {message_id}\n"
|
||||
f"Группа: {group_id}\n"
|
||||
f"Расписание: {cron_expr}"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании расписания: {e}")
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
|
||||
# /schedule remove job_id
|
||||
elif context.args and context.args[0] == 'remove':
|
||||
if len(context.args) < 2:
|
||||
await update.message.reply_text(
|
||||
"❌ Использование: /schedule remove <job_id>"
|
||||
)
|
||||
return
|
||||
|
||||
job_id = context.args[1]
|
||||
success = await cancel_broadcast(job_id)
|
||||
|
||||
if success:
|
||||
await update.message.reply_text(f"✅ Расписание удалено: {job_id}")
|
||||
else:
|
||||
await update.message.reply_text(f"❌ Расписание не найдено: {job_id}")
|
||||
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
"📅 Управление расписанием\n\n"
|
||||
"Команды:\n"
|
||||
"/schedule list - показать все расписания\n"
|
||||
"/schedule add <msg_id> <group_id> <cron> - добавить расписание\n"
|
||||
"/schedule remove <job_id> - удалить расписание\n\n"
|
||||
"Примеры cron:\n"
|
||||
"0 9 * * * - ежедневно в 9:00 UTC\n"
|
||||
"0 9 * * MON - по понедельникам в 9:00\n"
|
||||
"*/30 * * * * - каждые 30 минут"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в команде schedule: {e}")
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
async def initialize_scheduler(context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Инициализировать планировщик при запуске бота"""
|
||||
try:
|
||||
await broadcast_scheduler.initialize()
|
||||
broadcast_scheduler.start()
|
||||
await broadcast_scheduler.add_maintenance_schedules()
|
||||
logger.info("✅ Планировщик инициализирован и запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при инициализации планировщика: {e}")
|
||||
|
||||
|
||||
async def shutdown_scheduler(context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Завершить планировщик при остановке бота"""
|
||||
try:
|
||||
await broadcast_scheduler.shutdown()
|
||||
logger.info("✅ Планировщик завершен")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при завершении планировщика: {e}")
|
||||
124
app/handlers/sender.py
Normal file
124
app/handlers/sender.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.database.repository import GroupRepository, MessageRepository, MessageGroupRepository
|
||||
from app.utils.keyboards import get_back_keyboard, CallbackType
|
||||
from app.utils import can_send_message
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Отправить сообщение в группы с учетом slow mode"""
|
||||
query = update.callback_query
|
||||
|
||||
# Парсим callback: send_msg_<message_id>
|
||||
callback_data = query.data
|
||||
if callback_data.startswith("send_msg_"):
|
||||
message_id = int(callback_data.split("_")[2])
|
||||
else:
|
||||
await query.answer("❌ Ошибка обработки", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
msg_repo = MessageRepository(session)
|
||||
group_repo = GroupRepository(session)
|
||||
mg_repo = MessageGroupRepository(session)
|
||||
|
||||
message = await msg_repo.get_message(message_id)
|
||||
if not message:
|
||||
await query.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
# Получить группы, куда нужно отправить
|
||||
message_groups = await mg_repo.get_message_groups_to_send(message_id)
|
||||
|
||||
if not message_groups:
|
||||
await query.answer("✅ Сообщение уже отправлено во все группы", show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
await query.edit_message_text(
|
||||
f"📤 Начинаю отправку '{message.title}' в {len(message_groups)} групп(ы)...\n\n"
|
||||
"⏳ Это может занять некоторое время в зависимости от slow mode."
|
||||
)
|
||||
|
||||
# Отправляем в каждую группу
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
total_wait = 0
|
||||
|
||||
for mg in message_groups:
|
||||
try:
|
||||
# Проверяем slow mode
|
||||
can_send, wait_time = await can_send_message(mg.group)
|
||||
|
||||
if not can_send:
|
||||
# Ждем
|
||||
await query.edit_message_text(
|
||||
f"📤 Отправка '{message.title}'...\n\n"
|
||||
f"✅ Отправлено: {sent_count}\n"
|
||||
f"❌ Ошибок: {failed_count}\n"
|
||||
f"⏳ Ожидание {wait_time}s перед отправкой в {mg.group.title}..."
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
total_wait += wait_time
|
||||
|
||||
# Отправляем сообщение
|
||||
await context.bot.send_message(
|
||||
chat_id=mg.group.chat_id,
|
||||
text=message.text,
|
||||
parse_mode=message.parse_mode
|
||||
)
|
||||
|
||||
# Отмечаем как отправленное
|
||||
async with AsyncSessionLocal() as session:
|
||||
mg_repo = MessageGroupRepository(session)
|
||||
await mg_repo.mark_as_sent(mg.id)
|
||||
group_repo = GroupRepository(session)
|
||||
await group_repo.update_last_message_time(mg.group.id)
|
||||
|
||||
sent_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке в группу {mg.group.chat_id}: {e}")
|
||||
async with AsyncSessionLocal() as session:
|
||||
mg_repo = MessageGroupRepository(session)
|
||||
await mg_repo.mark_as_sent(mg.id, error=str(e))
|
||||
failed_count += 1
|
||||
|
||||
# Обновляем сообщение каждые 5 отправок
|
||||
if sent_count % 5 == 0:
|
||||
await query.edit_message_text(
|
||||
f"📤 Отправка '{message.title}'...\n\n"
|
||||
f"✅ Отправлено: {sent_count}\n"
|
||||
f"❌ Ошибок: {failed_count}"
|
||||
)
|
||||
|
||||
# Финальное сообщение
|
||||
final_text = f"✅ <b>Отправка завершена</b>\n\n"
|
||||
final_text += f"✅ Успешно: {sent_count}\n"
|
||||
final_text += f"❌ Ошибок: {failed_count}\n"
|
||||
if total_wait > 0:
|
||||
final_text += f"⏳ Всего ожидалось: {total_wait}s"
|
||||
|
||||
await query.edit_message_text(
|
||||
final_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=get_back_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def discover_groups(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
Обнаружить все группы, в которых есть бот
|
||||
Этот метод вызывается при запуске или по команде
|
||||
"""
|
||||
# Получить список всех чатов, в которых есть бот
|
||||
# NOTE: python-telegram-bot не имеет встроенного способа получить все чаты
|
||||
# Это нужно реализовать через webhook или polling с сохранением информации о новых группах
|
||||
|
||||
logger.info("Функция обнаружения групп - необходимо добавить обработчик my_chat_member")
|
||||
281
app/handlers/telethon_client.py
Normal file
281
app/handlers/telethon_client.py
Normal file
@@ -0,0 +1,281 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
from telethon import TelegramClient, events
|
||||
from telethon.tl.types import ChatMember, User
|
||||
from telethon.errors import (
|
||||
FloodWaitError, UserDeactivatedError, ChatAdminRequiredError,
|
||||
PeerIdInvalidError, ChannelInvalidError, UserNotParticipantError
|
||||
)
|
||||
from app.settings import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelethonClientManager:
|
||||
"""Менеджер для работы с Telethon клиентом"""
|
||||
|
||||
def __init__(self):
|
||||
self.client: Optional[TelegramClient] = None
|
||||
self.is_initialized = False
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Инициализировать Telethon клиент"""
|
||||
try:
|
||||
if not Config.USE_TELETHON:
|
||||
logger.warning("Telethon отключен в конфигурации")
|
||||
return False
|
||||
|
||||
if not (Config.TELETHON_API_ID and Config.TELETHON_API_HASH):
|
||||
logger.error("TELETHON_API_ID или TELETHON_API_HASH не установлены")
|
||||
return False
|
||||
|
||||
# Получить путь для сессии
|
||||
session_dir = os.path.join(os.path.dirname(__file__), '..', 'sessions')
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
session_path = os.path.join(session_dir, 'telethon_session')
|
||||
|
||||
self.client = TelegramClient(
|
||||
session_path,
|
||||
api_id=Config.TELETHON_API_ID,
|
||||
api_hash=Config.TELETHON_API_HASH
|
||||
)
|
||||
|
||||
await self.client.connect()
|
||||
|
||||
# Проверить авторизацию
|
||||
if not await self.client.is_user_authorized():
|
||||
logger.error("Telethon клиент не авторизован. Требуется повторный вход")
|
||||
return False
|
||||
|
||||
self.is_initialized = True
|
||||
me = await self.client.get_me()
|
||||
logger.info(f"✅ Telethon клиент инициализирован: {me.first_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при инициализации Telethon: {e}")
|
||||
return False
|
||||
|
||||
async def shutdown(self):
|
||||
"""Остановить Telethon клиент"""
|
||||
if self.client and self.is_initialized:
|
||||
try:
|
||||
await self.client.disconnect()
|
||||
self.is_initialized = False
|
||||
logger.info("✅ Telethon клиент остановлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при остановке Telethon: {e}")
|
||||
|
||||
async def send_message(self, chat_id: int, text: str,
|
||||
parse_mode: str = "html",
|
||||
disable_web_page_preview: bool = True) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение в чат
|
||||
|
||||
Returns:
|
||||
Optional[int]: ID отправленного сообщения или None при ошибке
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
message = await self.client.send_message(
|
||||
chat_id,
|
||||
text,
|
||||
parse_mode=parse_mode,
|
||||
link_preview=not disable_web_page_preview
|
||||
)
|
||||
logger.info(f"✅ Сообщение отправлено в чат {chat_id} (Telethon)")
|
||||
return message.id
|
||||
|
||||
except FloodWaitError as e:
|
||||
logger.warning(f"⏳ FloodWait: нужно ждать {e.seconds} секунд")
|
||||
raise
|
||||
|
||||
except (ChatAdminRequiredError, UserNotParticipantError):
|
||||
logger.error(f"❌ Клиент не администратор или не участник чата {chat_id}")
|
||||
return None
|
||||
|
||||
except PeerIdInvalidError:
|
||||
logger.error(f"❌ Неверный ID чата: {chat_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
async def get_chat_members(self, chat_id: int, limit: int = None) -> List[Dict]:
|
||||
"""
|
||||
Получить список участников чата
|
||||
|
||||
Returns:
|
||||
List[Dict]: Список участников с информацией
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return []
|
||||
|
||||
try:
|
||||
members = []
|
||||
async for member in self.client.iter_participants(chat_id, limit=limit):
|
||||
member_info = {
|
||||
'user_id': str(member.id),
|
||||
'username': member.username,
|
||||
'first_name': member.first_name,
|
||||
'last_name': member.last_name,
|
||||
'is_bot': member.bot,
|
||||
'is_admin': member.is_self, # self-check для упрощения
|
||||
}
|
||||
members.append(member_info)
|
||||
|
||||
logger.info(f"✅ Получено {len(members)} участников из чата {chat_id}")
|
||||
return members
|
||||
|
||||
except (ChatAdminRequiredError, UserNotParticipantError):
|
||||
logger.error(f"❌ Нет прав получить участников чата {chat_id}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении участников: {e}")
|
||||
return []
|
||||
|
||||
async def get_chat_info(self, chat_id: int) -> Optional[Dict]:
|
||||
"""Получить информацию о чате"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
chat = await self.client.get_entity(chat_id)
|
||||
|
||||
members_count = None
|
||||
if hasattr(chat, 'participants_count'):
|
||||
members_count = chat.participants_count
|
||||
|
||||
return {
|
||||
'id': chat.id,
|
||||
'title': chat.title if hasattr(chat, 'title') else str(chat.id),
|
||||
'description': chat.about if hasattr(chat, 'about') else None,
|
||||
'members_count': members_count,
|
||||
'is_supergroup': hasattr(chat, 'megagroup') and chat.megagroup,
|
||||
'is_channel': hasattr(chat, 'broadcast'),
|
||||
'is_group': hasattr(chat, 'gigagroup')
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении информации о чате {chat_id}: {e}")
|
||||
return None
|
||||
|
||||
async def join_chat(self, chat_link: str) -> Optional[int]:
|
||||
"""
|
||||
Присоединиться к чату по ссылке
|
||||
|
||||
Returns:
|
||||
Optional[int]: ID чата или None при ошибке
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Попытаться присоединиться
|
||||
result = await self.client(ImportChatInviteRequest(hash))
|
||||
chat_id = result.chats[0].id
|
||||
logger.info(f"✅ Присоединился к чату: {chat_id}")
|
||||
return chat_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при присоединении к чату: {e}")
|
||||
return None
|
||||
|
||||
async def leave_chat(self, chat_id: int) -> bool:
|
||||
"""Покинуть чат"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.client.delete_dialog(chat_id, revoke=True)
|
||||
logger.info(f"✅ Покинул чат: {chat_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при выходе из чата: {e}")
|
||||
return False
|
||||
|
||||
async def edit_message(self, chat_id: int, message_id: int, text: str) -> Optional[int]:
|
||||
"""
|
||||
Отредактировать сообщение
|
||||
|
||||
Returns:
|
||||
Optional[int]: ID отредактированного сообщения или None при ошибке
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
message = await self.client.edit_message(
|
||||
chat_id,
|
||||
message_id,
|
||||
text,
|
||||
parse_mode="html"
|
||||
)
|
||||
logger.info(f"✅ Сообщение отредактировано: {chat_id}/{message_id}")
|
||||
return message.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при редактировании сообщения: {e}")
|
||||
return None
|
||||
|
||||
async def delete_message(self, chat_id: int, message_id: int) -> bool:
|
||||
"""Удалить сообщение"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.client.delete_messages(chat_id, message_id)
|
||||
logger.info(f"✅ Сообщение удалено: {chat_id}/{message_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при удалении сообщения: {e}")
|
||||
return False
|
||||
|
||||
async def search_messages(self, chat_id: int, query: str, limit: int = 100) -> List[Dict]:
|
||||
"""
|
||||
Искать сообщения в чате
|
||||
|
||||
Returns:
|
||||
List[Dict]: Список найденных сообщений
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
logger.error("Telethon клиент не инициализирован")
|
||||
return []
|
||||
|
||||
try:
|
||||
messages = []
|
||||
async for message in self.client.iter_messages(chat_id, search=query, limit=limit):
|
||||
messages.append({
|
||||
'id': message.id,
|
||||
'text': message.text,
|
||||
'date': message.date
|
||||
})
|
||||
|
||||
logger.info(f"✅ Найдено {len(messages)} сообщений по запросу '{query}'")
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при поиске сообщений: {e}")
|
||||
return []
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Проверить, подключен ли клиент"""
|
||||
return self.is_initialized and self.client is not None
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера
|
||||
telethon_manager = TelethonClientManager()
|
||||
15
app/models/__init__.py
Normal file
15
app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .base import Base
|
||||
from .group import Group
|
||||
from .message import Message
|
||||
from .message_group import MessageGroup
|
||||
from .group_members import GroupMember, GroupKeyword, GroupStatistics
|
||||
|
||||
__all__ = [
|
||||
'Base',
|
||||
'Group',
|
||||
'Message',
|
||||
'MessageGroup',
|
||||
'GroupMember',
|
||||
'GroupKeyword',
|
||||
'GroupStatistics'
|
||||
]
|
||||
3
app/models/base.py
Normal file
3
app/models/base.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
33
app/models/group.py
Normal file
33
app/models/group.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Group(Base):
|
||||
"""Модель для хранения Telegram групп"""
|
||||
__tablename__ = 'groups'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
chat_id = Column(String, unique=True, nullable=False) # ID группы в Telegram
|
||||
title = Column(String, nullable=False) # Название группы
|
||||
slow_mode_delay = Column(Integer, default=0) # Задержка между сообщениями (сек)
|
||||
last_message_time = Column(DateTime, nullable=True) # Время последнего отправленного сообщения
|
||||
is_active = Column(Boolean, default=True) # Активна ли группа
|
||||
description = Column(String, nullable=True) # Описание группы (для поиска)
|
||||
member_count = Column(Integer, default=0) # Количество участников
|
||||
creator_id = Column(String, nullable=True) # ID создателя группы
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Связь со многими сообщениями (через таблицу связей)
|
||||
messages = relationship('MessageGroup', back_populates='group', cascade='all, delete-orphan')
|
||||
# Связь с участниками группы
|
||||
members = relationship('GroupMember', back_populates='group', cascade='all, delete-orphan')
|
||||
# Связь с ключевыми словами
|
||||
keywords = relationship('GroupKeyword', back_populates='group', cascade='all, delete-orphan')
|
||||
# Связь со статистикой
|
||||
statistics = relationship('GroupStatistics', back_populates='group', cascade='all, delete-orphan', uselist=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Group {self.chat_id} - {self.title}>'
|
||||
73
app/models/group_members.py
Normal file
73
app/models/group_members.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class GroupMember(Base):
|
||||
"""Модель для хранения участников группы"""
|
||||
__tablename__ = 'group_members'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
group_id = Column(Integer, ForeignKey('groups.id'), nullable=False)
|
||||
user_id = Column(String, nullable=False) # Telegram user ID
|
||||
username = Column(String, nullable=True) # Username если есть
|
||||
first_name = Column(String, nullable=True) # Имя
|
||||
last_name = Column(String, nullable=True) # Фамилия
|
||||
is_bot = Column(Boolean, default=False) # Это бот?
|
||||
is_admin = Column(Boolean, default=False) # Администратор группы?
|
||||
is_owner = Column(Boolean, default=False) # Владелец группы?
|
||||
joined_at = Column(DateTime, nullable=True) # Когда присоединился
|
||||
last_activity = Column(DateTime, default=datetime.utcnow) # Последняя активность
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Обратная связь
|
||||
group = relationship('Group', back_populates='members')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<GroupMember {self.user_id} in group {self.group_id}>'
|
||||
|
||||
|
||||
class GroupKeyword(Base):
|
||||
"""Модель для ключевых слов поиска групп"""
|
||||
__tablename__ = 'group_keywords'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
group_id = Column(Integer, ForeignKey('groups.id'), nullable=False, unique=True)
|
||||
keywords = Column(Text, nullable=False) # JSON массив ключевых слов
|
||||
description = Column(String, nullable=True) # Описание для поиска
|
||||
last_parsed = Column(DateTime, nullable=True) # Когда последний раз парсили
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Обратная связь
|
||||
group = relationship('Group', back_populates='keywords', foreign_keys=[group_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<GroupKeyword group_id={self.group_id}>'
|
||||
|
||||
|
||||
class GroupStatistics(Base):
|
||||
"""Модель для статистики групп"""
|
||||
__tablename__ = 'group_statistics'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
group_id = Column(Integer, ForeignKey('groups.id'), nullable=False, unique=True)
|
||||
total_members = Column(Integer, default=0) # Всего участников
|
||||
total_admins = Column(Integer, default=0) # Всего администраторов
|
||||
total_bots = Column(Integer, default=0) # Всего ботов
|
||||
messages_sent = Column(Integer, default=0) # Отправлено сообщений
|
||||
messages_failed = Column(Integer, default=0) # Ошибок при отправке
|
||||
messages_via_client = Column(Integer, default=0) # Отправлено через Pyrogram
|
||||
can_send_as_bot = Column(Boolean, default=True) # Может ли бот отправлять?
|
||||
can_send_as_client = Column(Boolean, default=False) # Может ли клиент отправлять?
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Обратная связь
|
||||
group = relationship('Group', back_populates='statistics', foreign_keys=[group_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<GroupStatistics group_id={self.group_id}>'
|
||||
23
app/models/message.py
Normal file
23
app/models/message.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""Модель для хранения сообщений для рассылки"""
|
||||
__tablename__ = 'messages'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
text = Column(String, nullable=False) # Текст сообщения
|
||||
title = Column(String, nullable=False) # Название/описание сообщения
|
||||
is_active = Column(Boolean, default=True) # Активно ли сообщение
|
||||
parse_mode = Column(String, default='HTML') # Режим парсинга (HTML, Markdown, None)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Связь со многими группами (через таблицу связей)
|
||||
groups = relationship('MessageGroup', back_populates='message', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Message {self.id} - {self.title}>'
|
||||
24
app/models/message_group.py
Normal file
24
app/models/message_group.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from .base import Base
|
||||
|
||||
|
||||
class MessageGroup(Base):
|
||||
"""Модель для связи между сообщениями и группами"""
|
||||
__tablename__ = 'message_groups'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
message_id = Column(Integer, ForeignKey('messages.id'), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey('groups.id'), nullable=False)
|
||||
is_sent = Column(Boolean, default=False) # Было ли отправлено в эту группу
|
||||
sent_at = Column(DateTime, nullable=True) # Время отправки
|
||||
error = Column(String, nullable=True) # Ошибка при отправке, если была
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Обратные связи
|
||||
message = relationship('Message', back_populates='groups')
|
||||
group = relationship('Group', back_populates='messages')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MessageGroup msg_id={self.message_id} group_id={self.group_id}>'
|
||||
210
app/scheduler.py
Normal file
210
app/scheduler.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Планировщик расписания для автоматических рассылок
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.settings import Config
|
||||
from app.database.repository import GroupRepository, MessageRepository, MessageGroupRepository
|
||||
from app.celery_tasks import broadcast_message_task, parse_group_members_task, cleanup_old_messages_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BroadcastScheduler:
|
||||
"""Планировщик для расписания рассылок"""
|
||||
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler(timezone='UTC')
|
||||
self.engine = None
|
||||
self.SessionLocal = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Инициализировать планировщик"""
|
||||
self.engine = create_async_engine(Config.DATABASE_URL, echo=False)
|
||||
self.SessionLocal = sessionmaker(self.engine, class_=AsyncSession, expire_on_commit=False)
|
||||
logger.info("✅ Планировщик инициализирован")
|
||||
|
||||
async def shutdown(self):
|
||||
"""Остановить планировщик"""
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
if self.engine:
|
||||
await self.engine.dispose()
|
||||
logger.info("✅ Планировщик остановлен")
|
||||
|
||||
def start(self):
|
||||
"""Запустить планировщик"""
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
logger.info("🚀 Планировщик запущен")
|
||||
|
||||
async def add_broadcast_schedule(self, message_id: int, group_ids: list, cron_expr: str,
|
||||
description: str = None):
|
||||
"""
|
||||
Добавить расписание для рассылки
|
||||
|
||||
Args:
|
||||
message_id: ID сообщения
|
||||
group_ids: Список ID групп
|
||||
cron_expr: Cron выражение (например "0 9 * * *" - ежедневно в 9:00)
|
||||
description: Описание задачи
|
||||
"""
|
||||
try:
|
||||
job_id = f"broadcast_{message_id}_{datetime.utcnow().timestamp()}"
|
||||
|
||||
self.scheduler.add_job(
|
||||
broadcast_message_task.delay,
|
||||
trigger=CronTrigger.from_crontab(cron_expr),
|
||||
args=(message_id, group_ids),
|
||||
id=job_id,
|
||||
name=description or f"Broadcast message {message_id}",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info(f"✅ Расписание добавлено: {job_id}")
|
||||
logger.info(f" Сообщение: {message_id}")
|
||||
logger.info(f" Группы: {group_ids}")
|
||||
logger.info(f" Расписание: {cron_expr}")
|
||||
|
||||
return job_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при добавлении расписания: {e}")
|
||||
raise
|
||||
|
||||
async def remove_broadcast_schedule(self, job_id: str):
|
||||
"""Удалить расписание"""
|
||||
try:
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"✅ Расписание удалено: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при удалении расписания: {e}")
|
||||
return False
|
||||
|
||||
async def list_schedules(self) -> list:
|
||||
"""Получить список всех расписаний"""
|
||||
jobs = []
|
||||
for job in self.scheduler.get_jobs():
|
||||
jobs.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'trigger': str(job.trigger),
|
||||
'next_run_time': job.next_run_time
|
||||
})
|
||||
return jobs
|
||||
|
||||
async def add_maintenance_schedules(self):
|
||||
"""Добавить периодические задачи обслуживания"""
|
||||
|
||||
# Очистка старых сообщений каждый день в 3:00 UTC
|
||||
self.scheduler.add_job(
|
||||
cleanup_old_messages_task.delay,
|
||||
trigger=CronTrigger.from_crontab('0 3 * * *'),
|
||||
id='cleanup_old_messages',
|
||||
name='Cleanup old messages',
|
||||
args=(Config.MESSAGE_HISTORY_DAYS,),
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("✅ Добавлена задача очистки старых сообщений (ежедневно 3:00 UTC)")
|
||||
|
||||
# Парсинг участников активных групп каждые 6 часов
|
||||
if Config.ENABLE_KEYWORD_PARSING and Config.GROUP_PARSE_INTERVAL > 0:
|
||||
self.scheduler.add_job(
|
||||
self._parse_all_groups,
|
||||
trigger=CronTrigger.from_crontab('0 */6 * * *'),
|
||||
id='parse_all_groups',
|
||||
name='Parse all group members',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("✅ Добавлена задача парсинга участников (каждые 6 часов)")
|
||||
|
||||
async def _parse_all_groups(self):
|
||||
"""Парсить участников всех активных групп"""
|
||||
try:
|
||||
async with self.SessionLocal() as session:
|
||||
group_repo = GroupRepository(session)
|
||||
groups = await group_repo.get_active_groups()
|
||||
|
||||
for group in groups:
|
||||
parse_group_members_task.delay(
|
||||
group.id,
|
||||
group.chat_id,
|
||||
limit=Config.MAX_MEMBERS_TO_LOAD
|
||||
)
|
||||
|
||||
logger.info(f"✅ Запущен парсинг {len(groups)} групп")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при парсинге групп: {e}")
|
||||
|
||||
async def pause_schedule(self, job_id: str):
|
||||
"""Приостановить расписание"""
|
||||
try:
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if job:
|
||||
job.pause()
|
||||
logger.info(f"⏸️ Расписание приостановлено: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при паузе расписания: {e}")
|
||||
return False
|
||||
|
||||
async def resume_schedule(self, job_id: str):
|
||||
"""Возобновить расписание"""
|
||||
try:
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if job:
|
||||
job.resume()
|
||||
logger.info(f"▶️ Расписание возобновлено: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при возобновлении расписания: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр планировщика
|
||||
broadcast_scheduler = BroadcastScheduler()
|
||||
|
||||
|
||||
# Вспомогательные функции для работы с расписанием
|
||||
async def schedule_broadcast(message_id: int, group_ids: list, cron_expr: str):
|
||||
"""Расписать рассылку сообщения"""
|
||||
return await broadcast_scheduler.add_broadcast_schedule(
|
||||
message_id=message_id,
|
||||
group_ids=group_ids,
|
||||
cron_expr=cron_expr,
|
||||
description=f"Broadcast message {message_id}"
|
||||
)
|
||||
|
||||
|
||||
async def cancel_broadcast(job_id: str):
|
||||
"""Отменить расписанную рассылку"""
|
||||
return await broadcast_scheduler.remove_broadcast_schedule(job_id)
|
||||
|
||||
|
||||
async def list_broadcasts():
|
||||
"""Получить список всех расписаний"""
|
||||
return await broadcast_scheduler.list_schedules()
|
||||
|
||||
|
||||
# Примеры cron выражений
|
||||
"""
|
||||
Cron формат: minute hour day month day_of_week
|
||||
|
||||
Примеры:
|
||||
- '0 9 * * *' - ежедневно в 9:00 UTC
|
||||
- '0 9 * * MON' - по понедельникам в 9:00 UTC
|
||||
- '0 */6 * * *' - каждые 6 часов
|
||||
- '0 9,14,18 * * *' - в 9:00, 14:00 и 18:00 UTC ежедневно
|
||||
- '*/30 * * * *' - каждые 30 минут
|
||||
- '0 0 * * *' - ежедневно в полночь UTC
|
||||
- '0 0 1 * *' - первого числа каждого месяца в полночь UTC
|
||||
- '0 0 * * 0' - по воскресеньям в полночь UTC
|
||||
"""
|
||||
160
app/settings.py
Normal file
160
app/settings.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Конфигурация приложения - загрузка переменных окружения
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузить .env файл
|
||||
env_path = Path(__file__).parent.parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
|
||||
|
||||
class Config:
|
||||
"""Базовая конфигурация"""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# TELEGRAM BOT
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
TELEGRAM_TIMEOUT = int(os.getenv('TELEGRAM_TIMEOUT', '30'))
|
||||
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
raise ValueError(
|
||||
"❌ TELEGRAM_BOT_TOKEN не установлен в .env\n"
|
||||
"Получите токен у @BotFather в Telegram"
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# TELETHON (для групп, где боты не могут писать)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
USE_TELETHON = os.getenv('USE_TELETHON', 'false').lower() == 'true'
|
||||
TELETHON_API_ID = os.getenv('TELETHON_API_ID', '')
|
||||
TELETHON_API_HASH = os.getenv('TELETHON_API_HASH', '')
|
||||
TELETHON_PHONE = os.getenv('TELETHON_PHONE', '')
|
||||
TELETHON_FLOOD_WAIT_MAX = int(os.getenv('TELETHON_FLOOD_WAIT_MAX', '60')) # Максимум ждать при FloodWait
|
||||
|
||||
if USE_TELETHON:
|
||||
if not TELETHON_API_ID or not TELETHON_API_HASH or not TELETHON_PHONE:
|
||||
raise ValueError(
|
||||
"❌ Для использования Telethon нужны:\n"
|
||||
" TELETHON_API_ID\n"
|
||||
" TELETHON_API_HASH\n"
|
||||
" TELETHON_PHONE\n"
|
||||
"Получите их на https://my.telegram.org"
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DATABASE
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///./autoposter.db')
|
||||
|
||||
# Альтернативная конфигурация PostgreSQL
|
||||
DB_USER = os.getenv('DB_USER')
|
||||
DB_PASSWORD = os.getenv('DB_PASSWORD')
|
||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||
DB_PORT = os.getenv('DB_PORT', '5432')
|
||||
DB_NAME = os.getenv('DB_NAME')
|
||||
|
||||
# Если указаны отдельные параметры, построить URL
|
||||
if DB_USER and DB_PASSWORD and DB_NAME:
|
||||
DATABASE_URL = (
|
||||
f'postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}'
|
||||
f'@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# LOGGING
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
LOG_MAX_SIZE = int(os.getenv('LOG_MAX_SIZE', '10485760'))
|
||||
LOG_BACKUP_COUNT = int(os.getenv('LOG_BACKUP_COUNT', '5'))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# BOT SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
MAX_RETRIES = int(os.getenv('MAX_RETRIES', '3'))
|
||||
RETRY_DELAY = int(os.getenv('RETRY_DELAY', '5'))
|
||||
MIN_SEND_INTERVAL = float(os.getenv('MIN_SEND_INTERVAL', '0.5')) # Минимум между отправками
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PARSING SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ENABLE_KEYWORD_PARSING = os.getenv('ENABLE_KEYWORD_PARSING', 'true').lower() == 'true'
|
||||
GROUP_PARSE_INTERVAL = int(os.getenv('GROUP_PARSE_INTERVAL', '3600'))
|
||||
MAX_MEMBERS_TO_LOAD = int(os.getenv('MAX_MEMBERS_TO_LOAD', '1000'))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# OPTIONAL SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ENABLE_STATISTICS = os.getenv('ENABLE_STATISTICS', 'true').lower() == 'true'
|
||||
MESSAGE_HISTORY_DAYS = int(os.getenv('MESSAGE_HISTORY_DAYS', '30'))
|
||||
WEBHOOK_URL = os.getenv('WEBHOOK_URL')
|
||||
WEBHOOK_PORT = int(os.getenv('WEBHOOK_PORT', '8443')) if os.getenv('WEBHOOK_PORT') else None
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# CELERY & REDIS SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
|
||||
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
|
||||
REDIS_DB = int(os.getenv('REDIS_DB', '0'))
|
||||
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '')
|
||||
|
||||
# Построить URL Redis
|
||||
if REDIS_PASSWORD:
|
||||
CELERY_BROKER_URL = f'redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}'
|
||||
CELERY_RESULT_BACKEND_URL = f'redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB + 1}'
|
||||
else:
|
||||
CELERY_BROKER_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}'
|
||||
CELERY_RESULT_BACKEND_URL = f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB + 1}'
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MODES
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
BOT_MODE = 'bot' # 'bot' для бота, 'client' для Telethon клиента
|
||||
USE_CLIENT_WHEN_BOT_FAILS = True # Использовать Telethon если бот не может отправить
|
||||
|
||||
@classmethod
|
||||
def get_mode(cls) -> str:
|
||||
"""Получить текущий режим работы"""
|
||||
if cls.USE_TELETHON:
|
||||
return 'hybrid' # Используем оба: бот и клиент
|
||||
return cls.BOT_MODE
|
||||
|
||||
@classmethod
|
||||
def get_database_url(cls) -> str:
|
||||
"""Получить URL БД"""
|
||||
return cls.DATABASE_URL
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> bool:
|
||||
"""Проверить конфигурацию"""
|
||||
# Основное - токен бота
|
||||
if not cls.TELEGRAM_BOT_TOKEN:
|
||||
return False
|
||||
|
||||
# Если Telethon включен - проверить его конфиг
|
||||
if cls.USE_TELETHON:
|
||||
if not (cls.TELETHON_API_ID and cls.TELETHON_API_HASH and cls.TELETHON_PHONE):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Создать экземпляр конфигурации
|
||||
config = Config()
|
||||
|
||||
# Выводы при импорте
|
||||
if config.get_mode() == 'hybrid':
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🔀 Гибридный режим: бот + Telethon клиент")
|
||||
elif config.USE_TELETHON:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("📱 Режим Telethon клиента")
|
||||
else:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🤖 Режим Telegram бота")
|
||||
40
app/utils/__init__.py
Normal file
40
app/utils/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Group
|
||||
|
||||
|
||||
async def can_send_message(group: Group) -> tuple[bool, int]:
|
||||
"""
|
||||
Проверить, можно ли отправить сообщение в группу с учетом slow mode
|
||||
Возвращает (можно_ли_отправить, сколько_секунд_ждать)
|
||||
"""
|
||||
if group.slow_mode_delay == 0:
|
||||
# Нет ограничений
|
||||
return True, 0
|
||||
|
||||
if group.last_message_time is None:
|
||||
# Первое сообщение
|
||||
return True, 0
|
||||
|
||||
time_since_last_message = datetime.utcnow() - group.last_message_time
|
||||
seconds_to_wait = group.slow_mode_delay - time_since_last_message.total_seconds()
|
||||
|
||||
if seconds_to_wait <= 0:
|
||||
return True, 0
|
||||
else:
|
||||
return False, int(seconds_to_wait) + 1
|
||||
|
||||
|
||||
async def wait_for_slow_mode(group: Group) -> int:
|
||||
"""
|
||||
Ждать, пока пройдет slow mode
|
||||
Возвращает реальное время ожидания в секундах
|
||||
"""
|
||||
can_send, wait_time = await can_send_message(group)
|
||||
if can_send:
|
||||
return 0
|
||||
|
||||
await asyncio.sleep(wait_time)
|
||||
return wait_time
|
||||
|
||||
|
||||
import asyncio
|
||||
89
app/utils/keyboards.py
Normal file
89
app/utils/keyboards.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CallbackType(str, Enum):
|
||||
"""Типы callback'ов для кнопок"""
|
||||
MANAGE_MESSAGES = "manage_messages"
|
||||
MANAGE_GROUPS = "manage_groups"
|
||||
CREATE_MESSAGE = "create_message"
|
||||
CREATE_GROUP = "create_group"
|
||||
VIEW_MESSAGE = "view_message"
|
||||
VIEW_GROUP = "view_group"
|
||||
DELETE_MESSAGE = "delete_message"
|
||||
DELETE_GROUP = "delete_group"
|
||||
ADD_TO_GROUP = "add_to_group"
|
||||
REMOVE_FROM_GROUP = "remove_from_group"
|
||||
SEND_NOW = "send_now"
|
||||
LIST_MESSAGES = "list_messages"
|
||||
LIST_GROUPS = "list_groups"
|
||||
BACK = "back"
|
||||
MAIN_MENU = "main_menu"
|
||||
|
||||
|
||||
def get_main_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Главное меню"""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton("📨 Сообщения", callback_data=CallbackType.MANAGE_MESSAGES),
|
||||
InlineKeyboardButton("👥 Группы", callback_data=CallbackType.MANAGE_GROUPS),
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_messages_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Меню управления сообщениями"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("➕ Новое сообщение", callback_data=CallbackType.CREATE_MESSAGE)],
|
||||
[InlineKeyboardButton("📜 Список сообщений", callback_data=CallbackType.LIST_MESSAGES)],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MAIN_MENU)],
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_groups_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Меню управления группами"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("➕ Добавить группу", callback_data=CallbackType.CREATE_GROUP)],
|
||||
[InlineKeyboardButton("📜 Список групп", callback_data=CallbackType.LIST_GROUPS)],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MAIN_MENU)],
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_back_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Кнопка назад"""
|
||||
keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.MAIN_MENU)]]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_message_actions_keyboard(message_id: int) -> InlineKeyboardMarkup:
|
||||
"""Действия с сообщением"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📤 Отправить", callback_data=f"send_msg_{message_id}")],
|
||||
[InlineKeyboardButton("🗑️ Удалить", callback_data=f"delete_msg_{message_id}")],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.LIST_MESSAGES)],
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_group_actions_keyboard(group_id: int) -> InlineKeyboardMarkup:
|
||||
"""Действия с группой"""
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📝 Сообщения", callback_data=f"group_messages_{group_id}")],
|
||||
[InlineKeyboardButton("🗑️ Удалить", callback_data=f"delete_group_{group_id}")],
|
||||
[InlineKeyboardButton("⬅️ Назад", callback_data=CallbackType.LIST_GROUPS)],
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def get_yes_no_keyboard(action: str) -> InlineKeyboardMarkup:
|
||||
"""Подтверждение да/нет"""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton("✅ Да", callback_data=f"confirm_{action}"),
|
||||
InlineKeyboardButton("❌ Нет", callback_data=CallbackType.MAIN_MENU),
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
Reference in New Issue
Block a user