diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..80b4f9a --- /dev/null +++ b/api_client.py @@ -0,0 +1,113 @@ +import aiohttp +import re +from typing import Optional, Tuple +from dataclasses import dataclass + + +@dataclass +class BumpResult: + success: bool + message: str + thread_id: str + + +@dataclass +class ThreadInfo: + thread_id: str + title: str + + +class APIClient: + + def __init__(self, base_url: str, auth_token: str): + self.base_url = base_url.rstrip('/') + self.auth_token = auth_token + self.session: Optional[aiohttp.ClientSession] = None + + def _clean_error_message(self, error_msg: str) -> str: + error_msg = re.sub(r'', '\n', error_msg) + error_msg = re.sub(r'<[^>]+>', '', error_msg) + + parts = [p.strip() for p in error_msg.split('\n') if p.strip()] + + if len(parts) > 1: + for part in parts: + if 'должны подождать' in part.lower() or 'должен подождать' in part.lower(): + return part + return parts[-1] + + return error_msg.strip() + + async def start(self): + if self.session is None or self.session.closed: + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.auth_token}", + "User-Agent": "AutoBumpBot/3.0", + "Content-Type": "application/json" + } + timeout = aiohttp.ClientTimeout(total=30) + self.session = aiohttp.ClientSession(headers=headers, timeout=timeout) + + async def close(self): + if self.session and not self.session.closed: + await self.session.close() + + async def get_thread_info(self, thread_id: str) -> Optional[ThreadInfo]: + if not self.session: + await self.start() + + url = f"{self.base_url}/threads/{thread_id}" + + try: + async with self.session.get(url) as resp: + if resp.status == 200: + data = await resp.json() + thread_data = data.get("thread", {}) + return ThreadInfo( + thread_id=thread_id, + title=thread_data.get("thread_title", "Unknown") + ) + except Exception as e: + print(f"Error getting thread info {thread_id}: {e}") + + return None + + async def bump_thread(self, thread_id: str) -> BumpResult: + if not self.session: + await self.start() + + url = f"{self.base_url}/threads/{thread_id}/bump" + + try: + async with self.session.post(url) as resp: + data = await resp.json() + + if "errors" in data and data["errors"]: + error_msg = data["errors"][0] + cleaned_msg = self._clean_error_message(error_msg) + return BumpResult( + success=False, + message=f"Тема {thread_id}: {cleaned_msg}", + thread_id=thread_id + ) + + if resp.status == 200: + return BumpResult( + success=True, + message=f"✅ Тема {thread_id} поднята успешно", + thread_id=thread_id + ) + + return BumpResult( + success=False, + message=f"Тема {thread_id}: HTTP {resp.status}", + thread_id=thread_id + ) + + except Exception as e: + return BumpResult( + success=False, + message=f"Тема {thread_id}: Ошибка - {str(e)}", + thread_id=thread_id + ) diff --git a/app.py b/app.py new file mode 100644 index 0000000..4079028 --- /dev/null +++ b/app.py @@ -0,0 +1,406 @@ +import asyncio +import logging +import sys +from datetime import datetime +from aiogram import Bot, Dispatcher, F +from aiogram.filters import Command, StateFilter +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage + +from config_manager import Config +from api_client import APIClient +from database import Database + + +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_threads = State() + + +class AutoBumpBot: + + def __init__(self): + self.config = Config.load() + self.bot = Bot(token=self.config.bot_token) + self.storage = MemoryStorage() + self.dp = Dispatcher(storage=self.storage) + self.api = APIClient(self.config.api_base_url, self.config.api_token) + self.db = Database(self.config.db_path) + self.is_running = False + self.total_bumps = 0 + self.successful_bumps = 0 + self.start_time = None + + self.dp.message.register(self.cmd_start, Command("start")) + self.dp.callback_query.register(self.cb_add_thread, F.data == "add_thread") + self.dp.callback_query.register(self.cb_list_threads, F.data == "list_threads") + self.dp.callback_query.register(self.cb_delete_menu, F.data == "delete_menu") + self.dp.callback_query.register(self.cb_delete_thread, F.data.startswith("delete_")) + self.dp.callback_query.register(self.cb_bump_now, F.data == "bump_now") + self.dp.callback_query.register(self.cb_stats, F.data == "stats") + self.dp.message.register(self.msg_add_threads, StateFilter(BotStates.waiting_for_threads)) + + def get_main_keyboard(self) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="➕ Добавить темы", callback_data="add_thread"), + InlineKeyboardButton(text="📋 Список тем", callback_data="list_threads") + ], + [ + InlineKeyboardButton(text="🗑️ Удалить тему", callback_data="delete_menu"), + InlineKeyboardButton(text="🚀 Поднять темы", callback_data="bump_now") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data="stats"), + InlineKeyboardButton(text="👤 Автор", url="https://lolz.live/kqlol/") + ] + ]) + return keyboard + + async def send_main_menu(self, chat_id: int, text: str = None): + if text is None: + text = "Выберите действие:" + + await self.bot.send_message( + chat_id, + text, + reply_markup=self.get_main_keyboard() + ) + + async def cmd_start(self, message: Message): + await message.answer_photo( + photo="https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg", + caption=( + "🤖 QIYANA AUTO-BUMP BOT\n\n" + "Бот для автоматического поднятия тем на Lolz.live\n\n" + f"⏰ Автоподнятие каждые {self.config.bump_interval_hours}ч" + ), + parse_mode="HTML" + ) + await self.send_main_menu(message.chat.id) + + async def cb_add_thread(self, callback: CallbackQuery, state: FSMContext): + await callback.answer() + await callback.message.answer( + "📝 Введите ID тем через запятую для добавления:\n" + "Пример: 12345, 67890, 11111", + parse_mode="HTML" + ) + await state.set_state(BotStates.waiting_for_threads) + + async def msg_add_threads(self, message: Message, state: FSMContext): + try: + thread_ids = [tid.strip() for tid in message.text.split(',') if tid.strip()] + + if not thread_ids: + await message.answer("❌ Не указаны ID тем") + return + + status_msg = await message.answer(f"⏳ Добавляю {len(thread_ids)} тем...") + + added = [] + errors = [] + + for thread_id in thread_ids: + if not thread_id.isdigit(): + errors.append(f"❌ {thread_id} - неверный формат") + continue + + thread_info = await self.api.get_thread_info(thread_id) + + if not thread_info: + errors.append(f"❌ {thread_id} - не найдена") + continue + + success = await self.db.add_thread(thread_id, thread_info.title) + + if success: + added.append(f"✅ {thread_id} - {thread_info.title}") + else: + errors.append(f"⚠️ {thread_id} - уже добавлена") + + result = f"📊 Результат добавления:\n\n" + + if added: + result += "✅ Добавлено:\n" + "\n".join(added) + "\n\n" + + if errors: + result += "❌ Ошибки:\n" + "\n".join(errors) + + await status_msg.edit_text(result, parse_mode="HTML") + await self.send_main_menu(message.chat.id) + + except Exception as e: + logger.error(f"Error adding threads: {e}", exc_info=True) + await message.answer(f"❌ Ошибка: {str(e)}") + finally: + await state.clear() + + async def cb_list_threads(self, callback: CallbackQuery): + await callback.answer() + + 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 = f"📋 Список тем ({len(threads)}):\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: + status = f"✅ {thread.last_bumped[:16]}" + + text += f"{thread.id} - {thread.title}\n{status}\n\n" + + await callback.message.answer(text, parse_mode="HTML") + 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)}") + + async def cb_delete_menu(self, callback: CallbackQuery): + await callback.answer() + + 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 = [] + for thread in threads: + button_text = f"{thread.id} - {thread.title[:30]}" + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"delete_{thread.id}" + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await callback.message.answer( + "🗑️ Выберите тему для удаления:", + reply_markup=keyboard + ) + + except Exception as e: + logger.error(f"Error showing delete menu: {e}", exc_info=True) + await callback.message.answer(f"❌ Ошибка: {str(e)}") + + async def cb_delete_thread(self, callback: CallbackQuery): + try: + thread_id = callback.data.split('_')[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} успешно удалена из базы данных") + await self.send_main_menu(callback.message.chat.id) + else: + await callback.answer(f"❌ Тема {thread_id} не найдена", show_alert=True) + + except Exception as e: + logger.error(f"Error deleting thread: {e}", exc_info=True) + await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + async def cb_bump_now(self, callback: CallbackQuery): + await callback.answer() + + 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" + "Подождите, это может занять некоторое время..." + ) + + results = await self.bump_threads(threads, send_to_chat=callback.message.chat.id) + + success_count = sum(1 for r in results if r.success) + + await status_msg.edit_text( + f"📊 Поднятие завершено!\n\n" + f"✅ Успешно: {success_count}\n" + f"❌ Ошибок: {len(threads) - success_count}\n" + f"📝 Всего тем: {len(threads)}", + parse_mode="HTML" + ) + + 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)}") + + async def cb_stats(self, callback: CallbackQuery): + await callback.answer() + + try: + threads = await self.db.get_all_threads() + threads_ready = await self.db.get_threads_to_bump(self.config.bump_interval_hours) + + uptime = "0 минут" + if self.start_time: + delta = datetime.now() - self.start_time + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + if hours > 0: + uptime = f"{hours}ч {minutes}м" + else: + uptime = f"{minutes}м" + + success_rate = 0 + if self.total_bumps > 0: + success_rate = (self.successful_bumps / self.total_bumps) * 100 + + text = ( + "📊 Статистика бота\n\n" + f"📋 Темы:\n" + f"• Всего в списке: {len(threads)}\n" + f"• Готовы к поднятию: {len(threads_ready)}\n" + f"• Ожидают времени: {len(threads) - len(threads_ready)}\n\n" + f"🚀 Поднятия:\n" + f"• Всего попыток: {self.total_bumps}\n" + f"• Успешных: {self.successful_bumps}\n" + f"• Успешность: {success_rate:.1f}%\n\n" + f"⚙️ Настройки:\n" + f"• Интервал: {self.config.bump_interval_hours}ч\n" + f"• Задержка между темами: 2с\n\n" + f"⏱️ Работа:\n" + f"• Время работы: {uptime}" + ) + + await callback.message.answer(text, parse_mode="HTML") + 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)}") + + async def bump_threads(self, threads, send_to_chat=None): + results = [] + + for i, thread in enumerate(threads, 1): + result = await self.api.bump_thread(thread.id) + results.append(result) + + self.total_bumps += 1 + if result.success: + self.successful_bumps += 1 + await self.db.update_last_bumped(thread.id) + + if send_to_chat: + await self.bot.send_message( + send_to_chat, + f"[{i}/{len(threads)}] {result.message}" + ) + + logger.info(f"Bump result: {result.message}") + + await asyncio.sleep(2) + + return results + + async def auto_bump_loop(self): + logger.info(f"Auto-bump scheduler started (interval: {self.config.bump_interval_hours}h)") + logger.info("Waiting for manual first bump or timer...") + + while self.is_running: + try: + sleep_seconds = self.config.bump_interval_hours * 3600 + logger.info(f"Next scheduled bump in {self.config.bump_interval_hours} hours") + await asyncio.sleep(sleep_seconds) + + if not self.is_running: + break + + logger.info("Starting scheduled bump...") + + threads = await self.db.get_threads_to_bump(self.config.bump_interval_hours) + + if threads: + logger.info(f"Found {len(threads)} threads to bump") + results = await self.bump_threads(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 Exception as e: + logger.error(f"Error in auto_bump_loop: {e}", exc_info=True) + await asyncio.sleep(60) + + async def start(self): + try: + await self.db.init() + logger.info("Database initialized") + + await self.api.start() + logger.info("API client started") + + self.start_time = datetime.now() + + self.is_running = True + asyncio.create_task(self.auto_bump_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) + raise + + async def stop(self): + logger.info("Stopping bot...") + self.is_running = False + await self.api.close() + await self.bot.session.close() + logger.info("Bot stopped") + + +async def main(): + bot = AutoBumpBot() + 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) + finally: + await bot.stop() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Application terminated") diff --git a/config.json b/config.json new file mode 100644 index 0000000..7480b25 --- /dev/null +++ b/config.json @@ -0,0 +1,18 @@ +{ + "bot": { + "api_token": "", + "img_url": "https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg", + "author_url": "https://lolz.live/kqlol/" + }, + "api": { + "base_url": "https://api.zelenka.guru", + "auth_token": "" + }, + "database": { + "path": "threads.db" + }, + "scheduling": { + "bump_interval_hours": 12, + "enable_auto_bump": true + } +} \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..f9118e6 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path +from dataclasses import dataclass + + +@dataclass +class Config: + bot_token: str + api_base_url: str + api_token: str + db_path: str + bump_interval_hours: float + + @classmethod + def load(cls, config_path: str = "config.json") -> "Config": + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + + return cls( + bot_token=data["bot"]["api_token"], + api_base_url=data["api"]["base_url"], + api_token=data["api"]["auth_token"], + db_path=data["database"]["path"], + bump_interval_hours=data["scheduling"]["bump_interval_hours"] + ) diff --git a/database.py b/database.py new file mode 100644 index 0000000..2b87a71 --- /dev/null +++ b/database.py @@ -0,0 +1,76 @@ +import aiosqlite +from typing import List, Optional +from dataclasses import dataclass + + +@dataclass +class Thread: + id: str + title: str + last_bumped: Optional[str] = None + + +class Database: + + def __init__(self, db_path: str): + self.db_path = db_path + + async def init(self): + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + last_bumped TEXT + ) + """) + await db.commit() + + async def add_thread(self, thread_id: str, title: str) -> bool: + try: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT OR IGNORE INTO threads (id, title) VALUES (?, ?)", + (thread_id, title) + ) + await db.commit() + return True + except Exception as e: + print(f"Error adding thread {thread_id}: {e}") + return False + + async def get_all_threads(self) -> List[Thread]: + async with aiosqlite.connect(self.db_path) as db: + async with db.execute( + "SELECT id, title, last_bumped FROM threads ORDER BY id" + ) as cursor: + rows = await cursor.fetchall() + return [Thread(id=r[0], title=r[1], last_bumped=r[2]) for r in rows] + + async def get_threads_to_bump(self, interval_hours: float) -> List[Thread]: + async with aiosqlite.connect(self.db_path) as db: + async with db.execute(f""" + SELECT id, title, last_bumped FROM threads + WHERE last_bumped IS NULL + OR datetime(last_bumped, '+{interval_hours} hours') <= datetime('now') + ORDER BY last_bumped ASC NULLS FIRST + """) as cursor: + rows = await cursor.fetchall() + return [Thread(id=r[0], title=r[1], last_bumped=r[2]) for r in rows] + + async def update_last_bumped(self, thread_id: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "UPDATE threads SET last_bumped = datetime('now') WHERE id = ?", + (thread_id,) + ) + await db.commit() + + async def delete_thread(self, thread_id: str) -> bool: + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "DELETE FROM threads WHERE id = ?", + (thread_id,) + ) + await db.commit() + return cursor.rowcount > 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..688eb9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiogram==3.4.1 +aiohttp>=3.9.0 +aiosqlite>=0.19.0