mirror of
https://github.com/qiyanaitsme/QIYANASAutoPumpLOLZ.git
synced 2026-07-25 02:24:29 +00:00
823 lines
35 KiB
Python
823 lines
35 KiB
Python
"""Telegram bot for automatic thread bumping on Lolz.live — with dynamic settings & auth."""
|
||
|
||
import asyncio
|
||
import logging
|
||
import sys
|
||
from datetime import datetime
|
||
from typing import Any, Awaitable, Callable, Sequence
|
||
|
||
from aiogram import BaseMiddleware, Bot, Dispatcher, F, Router
|
||
from aiogram.client.default import DefaultBotProperties
|
||
from aiogram.enums import ParseMode
|
||
from aiogram.filters import Command, StateFilter
|
||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||
from aiogram.types import TelegramObject
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.fsm.storage.memory import MemoryStorage
|
||
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
|
||
|
||
from config_manager import Config
|
||
from api_client import APIClient, BumpResult
|
||
from database import Database, BumpStats, Thread
|
||
|
||
|
||
NOTIFICATION_DELAY_SECONDS = 0.8
|
||
BUTTON_TEXT_MAX_LENGTH = 30
|
||
AUTO_BUMP_RETRY_DELAY_SECONDS = 60
|
||
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||
handlers=[
|
||
logging.StreamHandler(sys.stdout),
|
||
logging.FileHandler("bot.log", encoding="utf-8"),
|
||
],
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BotStates(StatesGroup):
|
||
waiting_for_thread_ids = State()
|
||
waiting_for_interval = State()
|
||
waiting_for_batch_size = State()
|
||
|
||
|
||
class AuthMiddleware(BaseMiddleware):
|
||
def __init__(self, admin_user_id: int):
|
||
super().__init__()
|
||
self._admin_user_id = admin_user_id
|
||
|
||
async def __call__(
|
||
self,
|
||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||
event: TelegramObject,
|
||
data: dict[str, Any],
|
||
) -> Any:
|
||
from_user = getattr(event, "from_user", None)
|
||
if from_user is None:
|
||
cq = getattr(event, "callback_query", None)
|
||
if cq is not None:
|
||
from_user = cq.from_user
|
||
else:
|
||
msg = getattr(event, "message", None)
|
||
if msg is not None:
|
||
from_user = msg.from_user
|
||
|
||
if from_user is None:
|
||
return await handler(event, data)
|
||
|
||
if from_user.id != self._admin_user_id:
|
||
chat_id = None
|
||
msg = getattr(event, "message", None)
|
||
if msg is not None:
|
||
chat_id = msg.chat.id
|
||
else:
|
||
cq = getattr(event, "callback_query", None)
|
||
if cq is not None and cq.message is not None:
|
||
chat_id = cq.message.chat.id
|
||
|
||
if chat_id:
|
||
try:
|
||
bot: Bot = data["bot"]
|
||
await bot.send_message(chat_id, "⛔ Доступ запрещён.")
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
return await handler(event, data)
|
||
|
||
|
||
class AutoBumpBot:
|
||
__slots__ = (
|
||
"_config", "_bot", "_dp", "_router", "_api", "_db",
|
||
"_is_running", "_start_time", "_auto_bump_task",
|
||
)
|
||
|
||
def __init__(self, config: Config) -> None:
|
||
self._config = config
|
||
|
||
self._bot = Bot(
|
||
token=config.bot.api_token,
|
||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||
)
|
||
|
||
self._dp = Dispatcher(storage=MemoryStorage())
|
||
self._router = Router(name="main_router")
|
||
|
||
self._api = APIClient(
|
||
config.api.base_url,
|
||
config.api.auth_token,
|
||
config.api.batch_size,
|
||
)
|
||
self._db = Database(config.database.path)
|
||
|
||
self._is_running = False
|
||
self._start_time: datetime | None = None
|
||
self._auto_bump_task: asyncio.Task | None = None
|
||
|
||
self._register_handlers()
|
||
self._dp.include_router(self._router)
|
||
self._dp.update.middleware(AuthMiddleware(config.admin_user_id))
|
||
|
||
def _register_handlers(self) -> None:
|
||
# Commands
|
||
self._router.message.register(
|
||
self._handle_start_command, Command("start")
|
||
)
|
||
# FSM state handlers
|
||
self._router.message.register(
|
||
self._handle_thread_ids_input,
|
||
StateFilter(BotStates.waiting_for_thread_ids),
|
||
)
|
||
self._router.message.register(
|
||
self._handle_interval_input,
|
||
StateFilter(BotStates.waiting_for_interval),
|
||
)
|
||
self._router.message.register(
|
||
self._handle_batch_size_input,
|
||
StateFilter(BotStates.waiting_for_batch_size),
|
||
)
|
||
# Callbacks
|
||
self._router.callback_query.register(self._handle_add_thread_callback, F.data == "add_thread")
|
||
self._router.callback_query.register(self._handle_list_threads_callback, F.data == "list_threads")
|
||
self._router.callback_query.register(self._handle_delete_menu_callback, F.data == "delete_menu")
|
||
self._router.callback_query.register(self._handle_delete_thread_callback, F.data.startswith("delete_"))
|
||
self._router.callback_query.register(self._handle_bump_now_callback, F.data == "bump_now")
|
||
self._router.callback_query.register(self._handle_stats_callback, F.data == "stats")
|
||
self._router.callback_query.register(self._handle_refresh_titles_callback, F.data == "refresh_titles")
|
||
self._router.callback_query.register(self._handle_settings_callback, F.data == "settings")
|
||
self._router.callback_query.register(self._handle_set_interval_callback, F.data == "set_interval")
|
||
self._router.callback_query.register(self._handle_set_batch_size_callback, F.data == "set_batch_size")
|
||
self._router.callback_query.register(self._handle_toggle_auto_bump_callback, F.data == "toggle_auto_bump")
|
||
self._router.callback_query.register(self._handle_back_to_menu_callback, F.data == "back_to_menu")
|
||
|
||
# ─── Keyboards ─────────────────────────────────────────────
|
||
|
||
def _main_menu_kb(self) -> InlineKeyboardMarkup:
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(text="➕ Add topics", callback_data="add_thread"),
|
||
InlineKeyboardButton(text="📋 List of topics", callback_data="list_threads"),
|
||
],
|
||
[
|
||
InlineKeyboardButton(text="🗑️ Delete topic", callback_data="delete_menu"),
|
||
InlineKeyboardButton(text="🚀 Bump topics", callback_data="bump_now"),
|
||
],
|
||
[
|
||
InlineKeyboardButton(text="🔄 Refresh", callback_data="refresh_titles"),
|
||
InlineKeyboardButton(text="📊 Statistics", callback_data="stats"),
|
||
],
|
||
[
|
||
InlineKeyboardButton(text="🛠️ Settings", callback_data="settings"),
|
||
InlineKeyboardButton(text="👤 Author", url=self._config.bot.author_url),
|
||
],
|
||
])
|
||
|
||
def _settings_kb(self) -> InlineKeyboardMarkup:
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="⏰ Set Interval", callback_data="set_interval")],
|
||
[InlineKeyboardButton(text="📦 Set Batch Size", callback_data="set_batch_size")],
|
||
[InlineKeyboardButton(text="🔄 Toggle Auto-Bump", callback_data="toggle_auto_bump")],
|
||
[InlineKeyboardButton(text="↩️ Back to Menu", callback_data="back_to_menu")],
|
||
])
|
||
|
||
async def _send_main_menu(self, chat_id: int, text: str = "Choose an action:") -> None:
|
||
await self._bot.send_photo(
|
||
chat_id,
|
||
photo=self._config.bot.img_url,
|
||
caption=text,
|
||
reply_markup=self._main_menu_kb(),
|
||
)
|
||
|
||
# ─── Utilities ──────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _truncate_text_safely(text: str, max_length: int) -> str:
|
||
if len(text) <= max_length:
|
||
return text
|
||
truncated = text[:max_length]
|
||
try:
|
||
truncated.encode("utf-8")
|
||
return truncated
|
||
except UnicodeEncodeError:
|
||
return AutoBumpBot._truncate_text_safely(text[: max_length - 1], max_length - 1)
|
||
|
||
@staticmethod
|
||
def _calculate_uptime(start_time: datetime) -> str:
|
||
delta = datetime.now() - start_time
|
||
total_seconds = int(delta.total_seconds())
|
||
days = total_seconds // 86400
|
||
hours = (total_seconds % 86400) // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
if days > 0:
|
||
return f"{days}d {hours}h {minutes}m"
|
||
elif hours > 0:
|
||
return f"{hours}h {minutes}m"
|
||
else:
|
||
return f"{minutes}m"
|
||
|
||
async def _get_interval(self) -> float:
|
||
val = await self._db.get_setting("bump_interval_hours", "12")
|
||
return float(val)
|
||
|
||
async def _get_batch_size(self) -> int:
|
||
val = await self._db.get_setting("batch_size", "10")
|
||
return int(val)
|
||
|
||
async def _is_auto_bump_enabled(self) -> bool:
|
||
val = await self._db.get_setting("enable_auto_bump", "true")
|
||
return val.lower() == "true"
|
||
|
||
# ─── Start ─────────────────────────────────────────────────
|
||
|
||
async def _handle_start_command(self, message: Message) -> None:
|
||
try:
|
||
interval = await self._get_interval()
|
||
await message.answer_photo(
|
||
photo=self._config.bot.img_url,
|
||
caption=(
|
||
"🤖 <b>QIYANA AUTO-BUMP BOT</b>\n\n"
|
||
"Бот для автоматического поднятия тем на Lolz.live\n\n"
|
||
f"⏰ Автоподнятие каждые <b>{interval:.0f}ч</b>"
|
||
),
|
||
reply_markup=self._main_menu_kb(),
|
||
)
|
||
except TelegramBadRequest as e:
|
||
logger.error(f"Failed to send start message: {e}")
|
||
await message.answer("❌ Ошибка отправки сообщения. Попробуйте /start снова.")
|
||
|
||
# ─── Add Thread ─────────────────────────────────────────────
|
||
|
||
async def _handle_add_thread_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
await callback.message.answer(
|
||
"📝 Введите ID тем через запятую для добавления:\n"
|
||
"Пример: <code>12345, 67890, 11111</code>"
|
||
)
|
||
await state.set_state(BotStates.waiting_for_thread_ids)
|
||
|
||
async def _handle_thread_ids_input(self, message: Message, state: FSMContext) -> None:
|
||
try:
|
||
thread_ids = [tid.strip() for tid in message.text.split(",") if tid.strip()]
|
||
|
||
if not thread_ids:
|
||
await message.answer("❌ Не указаны ID тем")
|
||
return
|
||
|
||
valid_ids: list[str] = []
|
||
errors: list[str] = []
|
||
|
||
for thread_id in thread_ids:
|
||
if not thread_id.isdigit():
|
||
errors.append(f"❌ {thread_id} - неверный формат")
|
||
else:
|
||
valid_ids.append(thread_id)
|
||
|
||
if not valid_ids:
|
||
await message.answer("❌ Нет валидных ID тем")
|
||
return
|
||
|
||
status_msg = await message.answer(
|
||
f"⏳ Добавляю {len(valid_ids)} тем и загружаю названия..."
|
||
)
|
||
|
||
# Fetch real titles via batch API
|
||
titles_map: dict[str, str] = {}
|
||
try:
|
||
threads_info = await self._api.get_threads_info_batch(valid_ids)
|
||
for i, info in enumerate(threads_info):
|
||
tid = valid_ids[i]
|
||
titles_map[tid] = info.title if info else f"Thread {tid}"
|
||
except Exception as e:
|
||
logger.error(f"Error fetching titles during add: {e}")
|
||
for tid in valid_ids:
|
||
titles_map.setdefault(tid, f"Thread {tid}")
|
||
|
||
added: list[str] = []
|
||
for thread_id in valid_ids:
|
||
title = titles_map.get(thread_id, f"Thread {thread_id}")
|
||
success = await self._db.add_thread(thread_id, title)
|
||
if success:
|
||
added.append(f"✅ {thread_id} — {title}")
|
||
else:
|
||
errors.append(f"⚠️ {thread_id} - уже добавлена")
|
||
|
||
result_parts = ["📊 Результат добавления:\n"]
|
||
if added:
|
||
result_parts.append("\n✅ <b>Добавлено:</b>\n")
|
||
result_parts.append("\n".join(added))
|
||
result_parts.append("\n")
|
||
if errors:
|
||
result_parts.append("\n❌ <b>Ошибки:</b>\n")
|
||
result_parts.append("\n".join(errors))
|
||
|
||
await status_msg.edit_text("".join(result_parts))
|
||
await self._send_main_menu(message.chat.id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in thread IDs input handler: {e}", exc_info=True)
|
||
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
||
finally:
|
||
await state.clear()
|
||
|
||
# ─── List Threads ───────────────────────────────────────────
|
||
|
||
async def _handle_list_threads_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
try:
|
||
threads = await self._db.get_all_threads()
|
||
if not threads:
|
||
await callback.message.answer("📭 Список тем пуст\n\nДобавьте темы через кнопку ➕")
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
return
|
||
|
||
text_parts = [f"📋 <b>Список тем ({len(threads)}):</b>\n\n"]
|
||
|
||
for thread in threads:
|
||
status = "🆕 Новая"
|
||
if thread.last_bumped:
|
||
try:
|
||
dt = datetime.fromisoformat(thread.last_bumped.replace("Z", "+00:00"))
|
||
status = f"✅ {dt.strftime('%d.%m.%Y %H:%M')}"
|
||
except ValueError:
|
||
status = f"✅ {thread.last_bumped[:16]}"
|
||
|
||
text_parts.append(
|
||
f"<b>ID:</b> {thread.id}\n"
|
||
f"<b>Название:</b> {thread.title}\n"
|
||
f"<b>Статус:</b> {status}\n\n"
|
||
)
|
||
|
||
await callback.message.answer("".join(text_parts))
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error listing threads: {e}", exc_info=True)
|
||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||
|
||
# ─── Refresh Titles ─────────────────────────────────────────
|
||
|
||
async def _handle_refresh_titles_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
try:
|
||
threads = await self._db.get_all_threads()
|
||
if not threads:
|
||
await callback.message.answer("📭 Список тем пуст")
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
return
|
||
|
||
status_msg = await callback.message.answer(
|
||
f"🔄 Обновляю названия {len(threads)} тем..."
|
||
)
|
||
|
||
thread_ids = [t.id for t in threads]
|
||
threads_info = await self._api.get_threads_info_batch(thread_ids)
|
||
|
||
updated = 0
|
||
for i, info in enumerate(threads_info):
|
||
if info and info.title and info.title != threads[i].title:
|
||
await self._db.update_thread_title(threads[i].id, info.title)
|
||
updated += 1
|
||
|
||
await status_msg.edit_text(
|
||
f"🔄 <b>Названия обновлены!</b>\n\n"
|
||
f"📝 Всего тем: {len(threads)}\n"
|
||
f"✅ Обновлено: {updated}\n"
|
||
f"⏭️ Без изменений: {len(threads) - updated}"
|
||
)
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error refreshing titles: {e}", exc_info=True)
|
||
await callback.message.answer(f"❌ Ошибка обновления: {str(e)}")
|
||
|
||
# ─── Delete Thread ──────────────────────────────────────────
|
||
|
||
async def _handle_delete_menu_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
try:
|
||
threads = await self._db.get_all_threads()
|
||
if not threads:
|
||
await callback.message.answer("📭 Список тем пуст")
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
return
|
||
|
||
buttons = [
|
||
[InlineKeyboardButton(
|
||
text=f"{thread.id} - {self._truncate_text_safely(thread.title, BUTTON_TEXT_MAX_LENGTH)}",
|
||
callback_data=f"delete_{thread.id}",
|
||
)]
|
||
for thread in threads
|
||
]
|
||
await callback.message.answer(
|
||
"🗑️ Выберите тему для удаления:",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error showing delete menu: {e}", exc_info=True)
|
||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||
|
||
async def _handle_delete_thread_callback(self, callback: CallbackQuery) -> None:
|
||
if not callback.message:
|
||
await callback.answer("❌ Ошибка: сообщение не найдено", show_alert=True)
|
||
return
|
||
try:
|
||
thread_id = callback.data.split("_", 1)[1]
|
||
success = await self._db.delete_thread(thread_id)
|
||
if success:
|
||
await callback.answer(f"✅ Тема {thread_id} удалена", show_alert=True)
|
||
await callback.message.answer(f"✅ Тема {thread_id} успешно удалена из базы данных")
|
||
else:
|
||
await callback.answer(f"❌ Тема {thread_id} не найдена", show_alert=True)
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
except Exception as e:
|
||
logger.error(f"Error deleting thread: {e}", exc_info=True)
|
||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||
|
||
# ─── Bump Now ───────────────────────────────────────────────
|
||
|
||
async def _handle_bump_now_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
try:
|
||
threads = await self._db.get_threads_to_bump(0)
|
||
if not threads:
|
||
await callback.message.answer("📭 Нет тем для поднятия\n\nДобавьте темы через кнопку ➕")
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
return
|
||
|
||
status_msg = await callback.message.answer(
|
||
f"🚀 Начинаю поднятие {len(threads)} тем...\n\n"
|
||
"Используется batch API (до 10 тем за запрос)\n"
|
||
"Подождите, это может занять некоторое время..."
|
||
)
|
||
|
||
results = await self._execute_bump_with_notifications(
|
||
threads, chat_id=callback.message.chat.id
|
||
)
|
||
|
||
success_count = sum(1 for r in results if r.success)
|
||
await status_msg.edit_text(
|
||
f"📊 <b>Поднятие завершено!</b>\n\n"
|
||
f"✅ Успешно: {success_count}\n"
|
||
f"❌ Ошибок: {len(threads) - success_count}\n"
|
||
f"📝 Всего тем: {len(threads)}"
|
||
)
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error bumping threads: {e}", exc_info=True)
|
||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||
|
||
# ─── Statistics ─────────────────────────────────────────────
|
||
|
||
async def _handle_stats_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
try:
|
||
threads = await self._db.get_all_threads()
|
||
interval = await self._get_interval()
|
||
threads_ready = await self._db.get_threads_to_bump(interval)
|
||
stats = await self._db.get_bump_stats()
|
||
batch_size = await self._get_batch_size()
|
||
auto_enabled = await self._is_auto_bump_enabled()
|
||
|
||
uptime = "0 минут"
|
||
if self._start_time:
|
||
uptime = self._calculate_uptime(self._start_time)
|
||
|
||
success_rate = 0.0
|
||
if stats.total_bumps > 0:
|
||
success_rate = (stats.successful_bumps / stats.total_bumps) * 100
|
||
|
||
text = (
|
||
"📊 <b>Статистика бота</b>\n\n"
|
||
f"📋 <b>Темы:</b>\n"
|
||
f"• Всего в списке: {len(threads)}\n"
|
||
f"• Готовы к поднятию: {len(threads_ready)}\n"
|
||
f"• Ожидают времени: {len(threads) - len(threads_ready)}\n\n"
|
||
f"🚀 <b>Поднятия:</b>\n"
|
||
f"• Всего попыток: {stats.total_bumps}\n"
|
||
f"• Успешных: {stats.successful_bumps}\n"
|
||
f"• Успешность: {success_rate:.1f}%\n"
|
||
f"• Последний бамп: {stats.last_bump_time or '—'}\n\n"
|
||
f"⚙️ <b>Настройки:</b>\n"
|
||
f"• Интервал: {interval:.0f}ч\n"
|
||
f"• Batch size: {batch_size}\n"
|
||
f"• Автобамп: {'✅ Вкл' if auto_enabled else '❌ Выкл'}\n\n"
|
||
f"⏱️ <b>Работа:</b>\n"
|
||
f"• Время работы: {uptime}"
|
||
)
|
||
|
||
await callback.message.answer(text)
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error showing stats: {e}", exc_info=True)
|
||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||
|
||
# ─── Settings ───────────────────────────────────────────────
|
||
|
||
async def _handle_settings_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
interval = await self._get_interval()
|
||
batch_size = await self._get_batch_size()
|
||
auto_enabled = await self._is_auto_bump_enabled()
|
||
|
||
text = (
|
||
"🛠️ <b>Текущие настройки</b>\n\n"
|
||
f"⏰ Интервал бампа: <b>{interval:.0f}ч</b>\n"
|
||
f"📦 Batch size: <b>{batch_size}</b>\n"
|
||
f"🔄 Автобамп: <b>{'✅ Вкл' if auto_enabled else '❌ Выкл'}</b>\n"
|
||
)
|
||
await callback.message.answer(text, reply_markup=self._settings_kb())
|
||
|
||
async def _handle_set_interval_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
interval = await self._get_interval()
|
||
await callback.message.answer(
|
||
f"Текущий интервал: <b>{interval:.0f}ч</b>\n\n"
|
||
"Введите новый интервал в часах (например: <code>6</code> или <code>24</code>):"
|
||
)
|
||
await state.set_state(BotStates.waiting_for_interval)
|
||
|
||
async def _handle_interval_input(self, message: Message, state: FSMContext) -> None:
|
||
try:
|
||
new_interval = float(message.text.strip())
|
||
if new_interval <= 0:
|
||
await message.answer("❌ Интервал должен быть положительным числом")
|
||
return
|
||
|
||
await self._db.set_setting("bump_interval_hours", str(new_interval))
|
||
logger.info(f"Bump interval changed to {new_interval}h by user {message.from_user.id}")
|
||
|
||
# Restart auto-bump loop if running
|
||
if self._is_running and await self._is_auto_bump_enabled():
|
||
self._restart_auto_bump()
|
||
|
||
await message.answer(f"✅ Интервал изменён на <b>{new_interval:.0f}ч</b>")
|
||
await self._send_main_menu(message.chat.id)
|
||
|
||
except ValueError:
|
||
await message.answer("❌ Введите число (например: <code>12</code>)")
|
||
except Exception as e:
|
||
logger.error(f"Error changing interval: {e}")
|
||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||
finally:
|
||
await state.clear()
|
||
|
||
async def _handle_set_batch_size_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
batch_size = await self._get_batch_size()
|
||
await callback.message.answer(
|
||
f"Текущий batch size: <b>{batch_size}</b>\n\n"
|
||
"Введите новый размер (от 1 до 10):"
|
||
)
|
||
await state.set_state(BotStates.waiting_for_batch_size)
|
||
|
||
async def _handle_batch_size_input(self, message: Message, state: FSMContext) -> None:
|
||
try:
|
||
new_size = int(message.text.strip())
|
||
if new_size < 1 or new_size > 10:
|
||
await message.answer("❌ Batch size должен быть от 1 до 10")
|
||
return
|
||
|
||
await self._db.set_setting("batch_size", str(new_size))
|
||
self._api._batch_size = new_size
|
||
logger.info(f"Batch size changed to {new_size} by user {message.from_user.id}")
|
||
|
||
await message.answer(f"✅ Batch size изменён на <b>{new_size}</b>")
|
||
await self._send_main_menu(message.chat.id)
|
||
|
||
except ValueError:
|
||
await message.answer("❌ Введите целое число (от 1 до 10)")
|
||
except Exception as e:
|
||
logger.error(f"Error changing batch size: {e}")
|
||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||
finally:
|
||
await state.clear()
|
||
|
||
async def _handle_toggle_auto_bump_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
|
||
current = await self._is_auto_bump_enabled()
|
||
new_state = not current
|
||
await self._db.set_setting("enable_auto_bump", str(new_state).lower())
|
||
|
||
if new_state:
|
||
self._restart_auto_bump()
|
||
status = "✅ Включён"
|
||
else:
|
||
status = "❌ Выключен"
|
||
|
||
logger.info(f"Auto-bump toggled to {new_state} by user {callback.from_user.id}")
|
||
await callback.message.answer(f"🔄 Автобамп: <b>{status}</b>")
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
# ─── Back to Menu ───────────────────────────────────────────
|
||
|
||
async def _handle_back_to_menu_callback(self, callback: CallbackQuery) -> None:
|
||
await callback.answer()
|
||
if not callback.message:
|
||
return
|
||
await self._send_main_menu(callback.message.chat.id)
|
||
|
||
# ─── Bump Execution ─────────────────────────────────────────
|
||
|
||
async def _execute_bump_with_notifications(
|
||
self, threads: Sequence[Thread], chat_id: int | None = None
|
||
) -> list[BumpResult]:
|
||
if not threads:
|
||
return []
|
||
|
||
thread_ids = [thread.id for thread in threads]
|
||
logger.info(f"Starting bump for {len(thread_ids)} threads: {thread_ids}")
|
||
|
||
results = await self._api.bump_threads_batch(thread_ids)
|
||
|
||
for result in results:
|
||
if result.success:
|
||
logger.info(
|
||
f"✅ BUMP SUCCESS | Thread: {result.thread_id} | "
|
||
f"Status: {result.status.value} | Message: {result.message}"
|
||
)
|
||
else:
|
||
logger.error(
|
||
f"❌ BUMP FAILED | Thread: {result.thread_id} | "
|
||
f"Status: {result.status.value} | Message: {result.message}"
|
||
)
|
||
|
||
success_count = 0
|
||
for result in results:
|
||
if result.success:
|
||
success_count += 1
|
||
await self._db.update_last_bumped(result.thread_id)
|
||
|
||
total = len(results)
|
||
await self._db.increment_bump_stats(success_count, total)
|
||
|
||
if chat_id:
|
||
await self._send_bump_notifications(chat_id, results)
|
||
|
||
logger.info(
|
||
f"Batch bump completed: {success_count}/{len(results)} successful | "
|
||
f"Failed: {len(results) - success_count}"
|
||
)
|
||
|
||
return results
|
||
|
||
async def _send_bump_notifications(self, chat_id: int, results: list[BumpResult]) -> None:
|
||
for i, result in enumerate(results, 1):
|
||
try:
|
||
await self._bot.send_message(chat_id, f"[{i}/{len(results)}] {result.message}")
|
||
if i < len(results):
|
||
await asyncio.sleep(NOTIFICATION_DELAY_SECONDS)
|
||
except TelegramRetryAfter as e:
|
||
logger.warning(f"Rate limit hit, waiting {e.retry_after} seconds")
|
||
await asyncio.sleep(e.retry_after)
|
||
try:
|
||
await self._bot.send_message(chat_id, f"[{i}/{len(results)}] {result.message}")
|
||
except Exception as retry_error:
|
||
logger.error(f"Failed to send notification after retry: {retry_error}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to send notification: {e}")
|
||
|
||
# ─── Auto-Bump Scheduler ───────────────────────────────────
|
||
|
||
def _restart_auto_bump(self) -> None:
|
||
if self._auto_bump_task and not self._auto_bump_task.done():
|
||
self._auto_bump_task.cancel()
|
||
self._auto_bump_task = asyncio.create_task(self._auto_bump_scheduler_loop())
|
||
|
||
async def _auto_bump_scheduler_loop(self) -> None:
|
||
logger.info("Auto-bump scheduler started")
|
||
while self._is_running:
|
||
try:
|
||
interval = await self._get_interval()
|
||
sleep_seconds = interval * 3600
|
||
logger.info(f"Next scheduled bump in {interval:.0f} hours")
|
||
await asyncio.sleep(sleep_seconds)
|
||
|
||
if not self._is_running:
|
||
break
|
||
|
||
auto_enabled = await self._is_auto_bump_enabled()
|
||
if not auto_enabled:
|
||
logger.info("Auto-bump disabled, skipping cycle")
|
||
continue
|
||
|
||
logger.info("Starting scheduled bump...")
|
||
threads = await self._db.get_threads_to_bump(interval)
|
||
|
||
if threads:
|
||
logger.info(f"Found {len(threads)} threads to bump")
|
||
results = await self._execute_bump_with_notifications(threads)
|
||
success_count = sum(1 for r in results if r.success)
|
||
logger.info(f"Scheduled bump completed: {success_count}/{len(threads)} successful")
|
||
else:
|
||
logger.info("No threads ready for scheduled bump")
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info("Auto-bump loop cancelled")
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"Error in auto_bump_loop: {e}", exc_info=True)
|
||
await asyncio.sleep(AUTO_BUMP_RETRY_DELAY_SECONDS)
|
||
|
||
# ─── Lifecycle ──────────────────────────────────────────────
|
||
|
||
async def start(self) -> None:
|
||
try:
|
||
await self._db.connect()
|
||
logger.info("Database connected")
|
||
|
||
await self._db.seed_defaults(self._config)
|
||
logger.info("Database seeded with default settings")
|
||
|
||
await self._api.start()
|
||
logger.info("API client started")
|
||
|
||
self._start_time = datetime.now()
|
||
self._is_running = True
|
||
|
||
if await self._is_auto_bump_enabled():
|
||
self._auto_bump_task = asyncio.create_task(self._auto_bump_scheduler_loop())
|
||
|
||
logger.info("Bot started successfully!")
|
||
await self._dp.start_polling(self._bot)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error starting bot: {e}", exc_info=True)
|
||
await self._cleanup_resources()
|
||
raise
|
||
|
||
async def stop(self) -> None:
|
||
logger.info("Stopping bot...")
|
||
self._is_running = False
|
||
if self._auto_bump_task and not self._auto_bump_task.done():
|
||
self._auto_bump_task.cancel()
|
||
await self._cleanup_resources()
|
||
logger.info("Bot stopped")
|
||
|
||
async def _cleanup_resources(self) -> None:
|
||
try:
|
||
await self._api.close()
|
||
except Exception as e:
|
||
logger.error(f"Error closing API client: {e}")
|
||
try:
|
||
await self._db.close()
|
||
except Exception as e:
|
||
logger.error(f"Error closing database: {e}")
|
||
try:
|
||
await self._bot.session.close()
|
||
except Exception as e:
|
||
logger.error(f"Error closing bot session: {e}")
|
||
|
||
|
||
async def main() -> None:
|
||
try:
|
||
config = Config.load()
|
||
logger.info("Configuration loaded from .env successfully")
|
||
except (FileNotFoundError, ValueError) as e:
|
||
logger.error(f"Configuration error: {e}")
|
||
sys.exit(1)
|
||
|
||
bot = AutoBumpBot(config)
|
||
|
||
try:
|
||
await bot.start()
|
||
except KeyboardInterrupt:
|
||
logger.info("Bot interrupted by user")
|
||
except Exception as e:
|
||
logger.error(f"Bot crashed: {e}", exc_info=True)
|
||
sys.exit(1)
|
||
finally:
|
||
await bot.stop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
asyncio.run(main())
|
||
except KeyboardInterrupt:
|
||
logger.info("Application terminated")
|