From 28d23b8de9a8df6b41c2865162209b0903bae752 Mon Sep 17 00:00:00 2001 From: Yuriy Yuriev Date: Thu, 21 May 2026 00:17:58 +0700 Subject: [PATCH] feat support bot --- handlers/commands.py | 16 +++-- managers/task_manager.py | 2 + services/irc_service.py | 87 ++++++++++++------------- support_bot.py | 134 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 50 deletions(-) create mode 100644 support_bot.py diff --git a/handlers/commands.py b/handlers/commands.py index deb3442..11d56a0 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -1906,7 +1906,8 @@ class BotInterface: f"📺 Стример: {params.channel}\n" f"🌐 Домены: {domains}\n\n" f"🔗 Ссылок найдено: {params.links_found}\n" - f"✅ Успешных кликов: {params.successful_visits}\n\n" + f"✅ Кликов: {params.successful_visits}/{params.total_planned_visits}\n" + f"⏳ В очереди: {params.pending_visits}\n\n" f"📊 CTR: {ctr_str}\n" f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n" f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек" @@ -1967,7 +1968,8 @@ class BotInterface: f"📺 Стример: {params.channel}\n" f"🌐 Домены: {domains}\n\n" f"🔗 Ссылок найдено: {params.links_found}\n" - f"✅ Успешных кликов: {params.successful_visits}\n\n" + f"✅ Кликов: {params.successful_visits}/{params.total_planned_visits}\n" + f"⏳ В очереди: {params.pending_visits}\n\n" f"📊 CTR: {ctr_str} от зрителей\n" f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n" f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек" @@ -2239,11 +2241,14 @@ class BotInterface: visits_count = max(1, round(params.visits_per_link * ctr / 100)) calc_info = f"зрители недоступны, фикс {params.visits_per_link} × {ctr:.2f}% = {visits_count} кликов" - logger.info(f"🔗 @{username} {url} → {visits_count} clicks ({calc_info})") + params.pending_visits += visits_count + params.total_planned_visits += visits_count + logger.info(f"🔗 @{username} {url} → {visits_count} clicks ({calc_info}), total pending: {params.pending_visits}") await send_message_safe( message.bot, message.chat.id, f"🔗 @{username}: {url[:60]}\n" - f"📊 {calc_info}" + f"📊 {calc_info}\n" + f"⏳ В очереди всего: {params.pending_visits} кликов" ) # Роут: [задержка] → клик1 → [задержка] → клик2 → ... → кликN @@ -2280,6 +2285,7 @@ class BotInterface: try: result = await self.browser_service.visit_page(url, reading) params.total_visits += 1 + params.pending_visits = max(0, params.pending_visits - 1) if result.success: params.successful_visits += 1 remaining -= 1 @@ -2287,6 +2293,7 @@ class BotInterface: new_balance = await self.balance_storage.deduct(params.user_id, 1) if new_balance <= 0: params.stopped = True + params.pending_visits = max(0, params.pending_visits - remaining) await send_message_safe( message.bot, params.chat_id, "⚠️ Баланс исчерпан — задача остановлена." @@ -2296,6 +2303,7 @@ class BotInterface: remaining -= 1 except Exception as e: logger.error(f"Visit error: {e}") + params.pending_visits = max(0, params.pending_visits - 1) remaining -= 1 # Клик завершён — проверяем паузу diff --git a/managers/task_manager.py b/managers/task_manager.py index 96642c2..56f8301 100644 --- a/managers/task_manager.py +++ b/managers/task_manager.py @@ -57,6 +57,8 @@ class TaskParams: total_visits: int = 0 successful_visits: int = 0 links_found: int = 0 + pending_visits: int = 0 # кликов в очереди (runtime, уменьшается) + total_planned_visits: int = 0 # всего запланировано кликов по всем ссылкам (runtime) chat_id: Optional[int] = None user_id: Optional[int] = None # Telegram user_id, если задача создана пользователем diff --git a/services/irc_service.py b/services/irc_service.py index c054c60..01ddc04 100644 --- a/services/irc_service.py +++ b/services/irc_service.py @@ -176,6 +176,11 @@ class TwitchIRCClient: start_time = datetime.now() last_message_time = datetime.now() + last_ping_time = datetime.now() + ping_interval = 20 # пинговать каждые 20 сек + pong_timeout = 10 # ждать PONG 10 сек, иначе реконнект + waiting_pong = False + ping_sent_at: Optional[float] = None drain_errors = 0 # Если задача стартует на паузе — ждём resume до подключения к IRC @@ -200,66 +205,54 @@ class TwitchIRCClient: logger.info(f"▶️ #{self.channel} resumed, reconnecting") await self.connect() last_message_time = datetime.now() + last_ping_time = datetime.now() + waiting_pong = False continue # Нет соединения — ждём перед повтором if not self._connected: await asyncio.sleep(5) await self.connect() + last_ping_time = datetime.now() + waiting_pong = False continue - # === ПЕРЕПОДКЛЮЧЕНИЕ КАЖДЫЕ 30 СЕКУНД === - if (datetime.now() - last_message_time).seconds > 30: - logger.info("🔄 Reconnecting (30s)...") + now = datetime.now() - # Сначала читаем всё что осталось (с retry) - drain_ok = False - for attempt in range(3): - try: - while True: - line = await asyncio.wait_for(self._reader.readline(), timeout=1.0) - if not line: - break - decoded = line.decode('utf-8', errors='ignore').strip() - if 'PRIVMSG' in decoded: - msg = self._parse_message(decoded) - if msg: - stats["messages"] += 1 - for url in self._extract_urls(msg['message']): - if self._is_domain_allowed(url, allowed_domains): - stats["links_found"] += 1 - try: - await on_url_found(url, msg['display_name']) - except Exception as e: - logger.warning(f"URL callback error: {e}") - drain_ok = True - break - except asyncio.TimeoutError: - drain_ok = True - break - except Exception as e: - logger.warning(f"Error draining IRC data (attempt {attempt + 1}/3): {e}") - await asyncio.sleep(2) + # === PONG не пришёл вовремя — соединение мёртвое, реконнект === + if waiting_pong and ping_sent_at and (now.timestamp() - ping_sent_at) > pong_timeout: + logger.warning(f"🔌 #{self.channel} PONG timeout, reconnecting...") + waiting_pong = False + self._connected = False + continue - if not drain_ok: + # === Отправляем PING каждые ping_interval секунд === + if not waiting_pong and (now - last_ping_time).seconds >= ping_interval: + try: + await self._send("PING :tmi.twitch.tv") + ping_sent_at = now.timestamp() + waiting_pong = True + last_ping_time = now + except Exception: + self._connected = False + continue + + # === Реконнект если вообще нет активности > 5 минут (failsafe) === + if (now - last_message_time).seconds > 300: + logger.info("🔄 No activity 5min, reconnecting...") + try: + await self.disconnect() + except Exception: + pass + await asyncio.sleep(1) + connected = await self.connect() + if not connected: drain_errors += 1 - logger.error(f"IRC drain failed 3 times, total failures: {drain_errors}") - if drain_errors >= 3: - logger.error("IRC drain failed 3 consecutive times — pausing task") - if on_drain_fail: - await on_drain_fail() + if drain_errors >= 3 and on_drain_fail: + await on_drain_fail() return stats else: drain_errors = 0 - - # Переподключаемся - try: - await self.disconnect() - except Exception as e: - logger.warning(f"Disconnect error: {e}") - await asyncio.sleep(1) - await self.connect() - stats["reconnects"] += 1 last_message_time = datetime.now() continue @@ -280,6 +273,8 @@ class TwitchIRCClient: continue if line.startswith('PONG'): + last_message_time = datetime.now() + waiting_pong = False # подтверждение что соединение живо continue msg = self._parse_message(line) diff --git a/support_bot.py b/support_bot.py new file mode 100644 index 0000000..ec0cf4f --- /dev/null +++ b/support_bot.py @@ -0,0 +1,134 @@ +""" +Бот поддержки. Пользователи пишут боту — сообщения приходят админу. +Админ отвечает reply'ем — ответ уходит пользователю. + +Запуск: python support_bot.py +Настройка в .env: + SUPPORT_BOT_TOKEN=... + SUPPORT_ADMIN_ID=... (ваш Telegram user_id) +""" + +import asyncio +import logging +import os +from dotenv import load_dotenv + +from aiogram import Bot, Dispatcher, F +from aiogram.types import Message +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.filters import CommandStart + +load_dotenv() + +TOKEN = os.getenv("SUPPORT_BOT_TOKEN", "") +ADMIN_ID = int(os.getenv("SUPPORT_ADMIN_ID", "0")) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +bot = Bot(token=TOKEN) +dp = Dispatcher() + +# user_id → message_id в чате с админом (для сопоставления ответов) +user_to_admin_msg: dict[int, int] = {} +# message_id в чате с админом → user_id +admin_msg_to_user: dict[int, int] = {} + + +@dp.message(CommandStart()) +async def cmd_start(message: Message): + await message.answer( + "👋 Привет! Напишите своё сообщение — мы ответим как можно скорее." + ) + + +@dp.message(F.chat.id == ADMIN_ID, F.reply_to_message) +async def admin_reply(message: Message): + """Админ отвечает reply'ем — пересылаем пользователю.""" + replied_id = message.reply_to_message.message_id + user_id = admin_msg_to_user.get(replied_id) + + if not user_id: + await message.answer("⚠️ Не удалось найти пользователя для этого сообщения.") + return + + try: + if message.text: + await bot.send_message(user_id, f"💬 Поддержка:\n\n{message.text}") + elif message.photo: + await bot.send_photo(user_id, message.photo[-1].file_id, caption=message.caption or "") + elif message.document: + await bot.send_document(user_id, message.document.file_id, caption=message.caption or "") + elif message.voice: + await bot.send_voice(user_id, message.voice.file_id) + elif message.sticker: + await bot.send_sticker(user_id, message.sticker.file_id) + else: + await bot.copy_message(user_id, message.chat.id, message.message_id) + + await message.react([{"type": "emoji", "emoji": "✅"}]) + except Exception as e: + await message.answer(f"❌ Не удалось отправить: {e}") + + +@dp.message(F.chat.id != ADMIN_ID) +async def user_message(message: Message): + """Сообщение от пользователя — пересылаем админу.""" + user = message.from_user + name = f"@{user.username}" if user.username else user.full_name + header = f"👤 {name} [{user.id}]:\n\n" + + try: + if message.text: + sent = await bot.send_message( + ADMIN_ID, + header + message.text, + parse_mode="HTML" + ) + elif message.photo: + sent = await bot.send_photo( + ADMIN_ID, + message.photo[-1].file_id, + caption=header + (message.caption or ""), + parse_mode="HTML" + ) + elif message.document: + sent = await bot.send_document( + ADMIN_ID, + message.document.file_id, + caption=header + (message.caption or ""), + parse_mode="HTML" + ) + elif message.voice: + sent = await bot.send_voice( + ADMIN_ID, + message.voice.file_id, + caption=header, + parse_mode="HTML" + ) + else: + await bot.send_message(ADMIN_ID, header, parse_mode="HTML") + sent = await bot.copy_message(ADMIN_ID, message.chat.id, message.message_id) + + # Запоминаем связку + admin_msg_to_user[sent.message_id] = user.id + user_to_admin_msg[user.id] = sent.message_id + + await message.answer("✅ Сообщение отправлено. Ожидайте ответа.") + except Exception as e: + logger.error(f"Error forwarding message: {e}") + await message.answer("❌ Ошибка отправки. Попробуйте позже.") + + +async def main(): + if not TOKEN: + raise ValueError("SUPPORT_BOT_TOKEN не задан в .env") + if not ADMIN_ID: + raise ValueError("SUPPORT_ADMIN_ID не задан в .env") + + logger.info(f"Support bot started, admin: {ADMIN_ID}") + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main())