feat support bot
This commit is contained in:
+12
-4
@@ -1906,7 +1906,8 @@ class BotInterface:
|
|||||||
f"📺 Стример: {params.channel}\n"
|
f"📺 Стример: {params.channel}\n"
|
||||||
f"🌐 Домены: {domains}\n\n"
|
f"🌐 Домены: {domains}\n\n"
|
||||||
f"🔗 Ссылок найдено: {params.links_found}\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"📊 CTR: {ctr_str}\n"
|
||||||
f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n"
|
f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n"
|
||||||
f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек"
|
f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек"
|
||||||
@@ -1967,7 +1968,8 @@ class BotInterface:
|
|||||||
f"📺 Стример: {params.channel}\n"
|
f"📺 Стример: {params.channel}\n"
|
||||||
f"🌐 Домены: {domains}\n\n"
|
f"🌐 Домены: {domains}\n\n"
|
||||||
f"🔗 Ссылок найдено: {params.links_found}\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"📊 CTR: {ctr_str} от зрителей\n"
|
||||||
f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n"
|
f"⏱ Пауза между кликами: {params.min_delay}–{params.max_delay} сек\n"
|
||||||
f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек"
|
f"📖 Время на странице: {params.min_reading}–{params.max_reading} сек"
|
||||||
@@ -2239,11 +2241,14 @@ class BotInterface:
|
|||||||
visits_count = max(1, round(params.visits_per_link * ctr / 100))
|
visits_count = max(1, round(params.visits_per_link * ctr / 100))
|
||||||
calc_info = f"зрители недоступны, фикс {params.visits_per_link} × {ctr:.2f}% = {visits_count} кликов"
|
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(
|
await send_message_safe(
|
||||||
message.bot, message.chat.id,
|
message.bot, message.chat.id,
|
||||||
f"🔗 @{username}: {url[:60]}\n"
|
f"🔗 @{username}: {url[:60]}\n"
|
||||||
f"📊 {calc_info}"
|
f"📊 {calc_info}\n"
|
||||||
|
f"⏳ В очереди всего: {params.pending_visits} кликов"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Роут: [задержка] → клик1 → [задержка] → клик2 → ... → кликN
|
# Роут: [задержка] → клик1 → [задержка] → клик2 → ... → кликN
|
||||||
@@ -2280,6 +2285,7 @@ class BotInterface:
|
|||||||
try:
|
try:
|
||||||
result = await self.browser_service.visit_page(url, reading)
|
result = await self.browser_service.visit_page(url, reading)
|
||||||
params.total_visits += 1
|
params.total_visits += 1
|
||||||
|
params.pending_visits = max(0, params.pending_visits - 1)
|
||||||
if result.success:
|
if result.success:
|
||||||
params.successful_visits += 1
|
params.successful_visits += 1
|
||||||
remaining -= 1
|
remaining -= 1
|
||||||
@@ -2287,6 +2293,7 @@ class BotInterface:
|
|||||||
new_balance = await self.balance_storage.deduct(params.user_id, 1)
|
new_balance = await self.balance_storage.deduct(params.user_id, 1)
|
||||||
if new_balance <= 0:
|
if new_balance <= 0:
|
||||||
params.stopped = True
|
params.stopped = True
|
||||||
|
params.pending_visits = max(0, params.pending_visits - remaining)
|
||||||
await send_message_safe(
|
await send_message_safe(
|
||||||
message.bot, params.chat_id,
|
message.bot, params.chat_id,
|
||||||
"⚠️ Баланс исчерпан — задача остановлена."
|
"⚠️ Баланс исчерпан — задача остановлена."
|
||||||
@@ -2296,6 +2303,7 @@ class BotInterface:
|
|||||||
remaining -= 1
|
remaining -= 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Visit error: {e}")
|
logger.error(f"Visit error: {e}")
|
||||||
|
params.pending_visits = max(0, params.pending_visits - 1)
|
||||||
remaining -= 1
|
remaining -= 1
|
||||||
|
|
||||||
# Клик завершён — проверяем паузу
|
# Клик завершён — проверяем паузу
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class TaskParams:
|
|||||||
total_visits: int = 0
|
total_visits: int = 0
|
||||||
successful_visits: int = 0
|
successful_visits: int = 0
|
||||||
links_found: int = 0
|
links_found: int = 0
|
||||||
|
pending_visits: int = 0 # кликов в очереди (runtime, уменьшается)
|
||||||
|
total_planned_visits: int = 0 # всего запланировано кликов по всем ссылкам (runtime)
|
||||||
chat_id: Optional[int] = None
|
chat_id: Optional[int] = None
|
||||||
user_id: Optional[int] = None # Telegram user_id, если задача создана пользователем
|
user_id: Optional[int] = None # Telegram user_id, если задача создана пользователем
|
||||||
|
|
||||||
|
|||||||
+41
-46
@@ -176,6 +176,11 @@ class TwitchIRCClient:
|
|||||||
|
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
last_message_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
|
drain_errors = 0
|
||||||
|
|
||||||
# Если задача стартует на паузе — ждём resume до подключения к IRC
|
# Если задача стартует на паузе — ждём resume до подключения к IRC
|
||||||
@@ -200,66 +205,54 @@ class TwitchIRCClient:
|
|||||||
logger.info(f"▶️ #{self.channel} resumed, reconnecting")
|
logger.info(f"▶️ #{self.channel} resumed, reconnecting")
|
||||||
await self.connect()
|
await self.connect()
|
||||||
last_message_time = datetime.now()
|
last_message_time = datetime.now()
|
||||||
|
last_ping_time = datetime.now()
|
||||||
|
waiting_pong = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Нет соединения — ждём перед повтором
|
# Нет соединения — ждём перед повтором
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
last_ping_time = datetime.now()
|
||||||
|
waiting_pong = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# === ПЕРЕПОДКЛЮЧЕНИЕ КАЖДЫЕ 30 СЕКУНД ===
|
now = datetime.now()
|
||||||
if (datetime.now() - last_message_time).seconds > 30:
|
|
||||||
logger.info("🔄 Reconnecting (30s)...")
|
|
||||||
|
|
||||||
# Сначала читаем всё что осталось (с retry)
|
# === PONG не пришёл вовремя — соединение мёртвое, реконнект ===
|
||||||
drain_ok = False
|
if waiting_pong and ping_sent_at and (now.timestamp() - ping_sent_at) > pong_timeout:
|
||||||
for attempt in range(3):
|
logger.warning(f"🔌 #{self.channel} PONG timeout, reconnecting...")
|
||||||
try:
|
waiting_pong = False
|
||||||
while True:
|
self._connected = False
|
||||||
line = await asyncio.wait_for(self._reader.readline(), timeout=1.0)
|
continue
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
drain_errors += 1
|
||||||
logger.error(f"IRC drain failed 3 times, total failures: {drain_errors}")
|
if drain_errors >= 3 and on_drain_fail:
|
||||||
if drain_errors >= 3:
|
await on_drain_fail()
|
||||||
logger.error("IRC drain failed 3 consecutive times — pausing task")
|
|
||||||
if on_drain_fail:
|
|
||||||
await on_drain_fail()
|
|
||||||
return stats
|
return stats
|
||||||
else:
|
else:
|
||||||
drain_errors = 0
|
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
|
stats["reconnects"] += 1
|
||||||
last_message_time = datetime.now()
|
last_message_time = datetime.now()
|
||||||
continue
|
continue
|
||||||
@@ -280,6 +273,8 @@ class TwitchIRCClient:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('PONG'):
|
if line.startswith('PONG'):
|
||||||
|
last_message_time = datetime.now()
|
||||||
|
waiting_pong = False # подтверждение что соединение живо
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg = self._parse_message(line)
|
msg = self._parse_message(line)
|
||||||
|
|||||||
+134
@@ -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} [<code>{user.id}</code>]:\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())
|
||||||
Reference in New Issue
Block a user