From 44ce29d2e031e78d2030380c1fc8a05dbed5b1b8 Mon Sep 17 00:00:00 2001 From: Yuriy Yuriev Date: Sun, 5 Jul 2026 16:59:33 +0700 Subject: [PATCH] fix --- .claude/settings.local.json | 10 ++- auth/storage.py | 5 ++ core/logger.py | 3 + handlers/commands.py | 138 +++++++++++++++++++++++++++++------ managers/storage.py | 12 +++ services/browser_service.py | 2 +- services/gql_chat_service.py | 13 +++- services/irc_service.py | 9 ++- services/webhook_server.py | 27 ++++++- 9 files changed, 188 insertions(+), 31 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d81e193..3a29abf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,15 @@ "Bash(pip install *)", "Bash(python -c \"import psutil; print\\('psutil ok, version:', psutil.__version__\\)\")", "Bash(python -c ' *)", - "WebFetch(domain:doc.heleket.com)" + "WebFetch(domain:doc.heleket.com)", + "Bash(chcp)", + "PowerShell(chcp)", + "PowerShell(python -c \"from core.logger import setup_logger; log = setup_logger\\('test'\\); log.info\\('?? @Nightbot ��� ��ਫ����: 15 ������'\\)\")", + "Read(//c/Users/user/AppData/Local/Programs/Python/**)", + "Bash(where.exe python *)", + "Bash(.venv/Scripts/python -c ' *)", + "Bash(/c/Work/PythonBot/.venv/Scripts/python.exe -c ' *)", + "PowerShell(& \"C:\\\\Work\\\\PythonBot\\\\.venv\\\\Scripts\\\\python.exe\" -c \"from core.logger import setup_logger; log = setup_logger\\('test'\\); log.info\\('test kirillicy klikov znak'\\)\")" ] } } diff --git a/auth/storage.py b/auth/storage.py index 1852c0a..ada89ae 100644 --- a/auth/storage.py +++ b/auth/storage.py @@ -62,6 +62,11 @@ class AuthStorage: data = await asyncio.to_thread(self._load_sync) return str(user_id) in data + async def get_all_user_ids(self) -> list[int]: + async with self._lock: + data = await asyncio.to_thread(self._load_sync) + return [int(k) for k in data.keys()] + def _load_sync(self) -> dict: if not self.file_path.exists(): return {} diff --git a/core/logger.py b/core/logger.py index bb7fb79..ac97133 100644 --- a/core/logger.py +++ b/core/logger.py @@ -28,6 +28,9 @@ def setup_logger( datefmt='%Y-%m-%d %H:%M:%S' ) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(log_level) console_handler.setFormatter(console_formatter) diff --git a/handlers/commands.py b/handlers/commands.py index 6ea050e..0c32347 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -69,6 +69,7 @@ class BotInterface: self._topup_confirm: Dict[int, int] = {} # user_id -> rub amount pending confirm self._user_task_state: Dict[int, dict] = {} # шаги создания задачи пользователем self._admin_balance_state: Dict[int, int] = {} # admin_id -> target_user_id + self._admin_rub_state: Dict[int, int] = {} # admin_id -> target_user_id self._admin_msg_state: Dict[int, int] = {} # admin_id -> target_user_id self._menu_msg: Dict[int, int] = {} # user_id -> inline keyboard message_id self._menu_top_msg: Dict[int, int] = {} # user_id -> reply keyboard message_id @@ -1241,6 +1242,24 @@ class BotInterface: pass await callback.answer() + @dp.callback_query(F.data.startswith("ubal_menu_")) + async def cb_user_balance_menu(callback: CallbackQuery): + if not await require_admin(callback): + return + target_uid = int(callback.data.replace("ubal_menu_", "", 1)) + builder = InlineKeyboardBuilder() + for amount in [10, 50, 100, 500]: + builder.button(text=f"+{amount}", callback_data=f"ubal_{amount}_{target_uid}") + builder.adjust(4) + builder.row(InlineKeyboardButton(text="✏️ Другая сумма", callback_data=f"ubal_custom_{target_uid}")) + builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=f"udetail_{target_uid}")) + clicks = await interface.balance_storage.get_balance(target_uid) + await callback.message.edit_text( + f"👤 {target_uid} — переходов: {clicks}\n\nДобавить переходов:", + reply_markup=builder.as_markup() + ) + await callback.answer() + @dp.callback_query(F.data.startswith("ubal_")) async def cb_user_balance(callback: CallbackQuery): if not await require_admin(callback): @@ -1252,7 +1271,7 @@ class BotInterface: interface._admin_balance_state[callback.from_user.id] = target_uid builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data=f"udetail_{target_uid}")) - await callback.message.answer("Введите сумму для пополнения:", reply_markup=builder.as_markup()) + await callback.message.answer("Введите количество переходов:", reply_markup=builder.as_markup()) await callback.answer() else: amount = int(parts[1]) @@ -1261,6 +1280,44 @@ class BotInterface: await callback.answer(f"✅ +{amount} переходов. Баланс: {new_balance}") await interface._show_user_detail(callback, target_uid) + @dp.callback_query(F.data.startswith("urub_menu_")) + async def cb_user_rub_menu(callback: CallbackQuery): + if not await require_admin(callback): + return + target_uid = int(callback.data.replace("urub_menu_", "", 1)) + builder = InlineKeyboardBuilder() + for amount in [100, 500, 1000, 3000]: + builder.button(text=f"+{amount}₽", callback_data=f"urub_{amount}_{target_uid}") + builder.adjust(4) + builder.row(InlineKeyboardButton(text="✏️ Другая сумма", callback_data=f"urub_custom_{target_uid}")) + builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=f"udetail_{target_uid}")) + rub = await interface.rub_storage.get_balance(target_uid) + await callback.message.edit_text( + f"👤 {target_uid} — рублей: {rub} ₽\n\nДобавить рублей:", + reply_markup=builder.as_markup() + ) + await callback.answer() + + @dp.callback_query(F.data.startswith("urub_")) + async def cb_user_rub(callback: CallbackQuery): + if not await require_admin(callback): + return + parts = callback.data.split("_") + # формат: urub_{amount}_{user_id} или urub_custom_{user_id} + if parts[1] == "custom": + target_uid = int(parts[2]) + interface._admin_rub_state[callback.from_user.id] = target_uid + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data=f"udetail_{target_uid}")) + await callback.message.answer("Введите сумму в рублях:", reply_markup=builder.as_markup()) + await callback.answer() + else: + amount = int(parts[1]) + target_uid = int(parts[2]) + new_balance = await interface.rub_storage.add_balance(target_uid, amount) + await callback.answer(f"✅ +{amount} ₽. Баланс: {new_balance} ₽") + await interface._show_user_detail(callback, target_uid) + @dp.callback_query(F.data.startswith("utasks_")) async def cb_user_tasks_admin(callback: CallbackQuery): if not await require_admin(callback): @@ -1376,7 +1433,7 @@ class BotInterface: await interface._show_user_menu(message) return - # Ввод суммы баланса (admin) + # Ввод количества переходов (admin) if user_id in interface._admin_balance_state: target_uid = interface._admin_balance_state.pop(user_id) await interface._safe_delete(message.bot, message.chat.id, message.message_id) @@ -1385,7 +1442,21 @@ class BotInterface: if amount <= 0: raise ValueError new_balance = await interface.balance_storage.add_balance(target_uid, amount) - await interface._send_temp(message, f"✅ Пополнено на {amount}. Баланс: {new_balance}") + await interface._send_temp(message, f"✅ +{amount} переходов. Баланс: {new_balance}") + except (ValueError, TypeError): + await interface._send_temp(message, "❌ Введите целое положительное число") + return + + # Ввод рублёвого пополнения (admin) + if user_id in interface._admin_rub_state: + target_uid = interface._admin_rub_state.pop(user_id) + await interface._safe_delete(message.bot, message.chat.id, message.message_id) + try: + amount = int(text) + if amount <= 0: + raise ValueError + new_balance = await interface.rub_storage.add_balance(target_uid, amount) + await interface._send_temp(message, f"✅ +{amount} ₽. Баланс: {new_balance} ₽") except (ValueError, TypeError): await interface._send_temp(message, "❌ Введите целое положительное число") return @@ -2389,7 +2460,7 @@ class BotInterface: 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( + notif_msg = await send_message_safe( message.bot, message.chat.id, f"🔗 @{username}: {url[:60]}\n" f"📊 {calc_info}\n" @@ -2402,6 +2473,7 @@ class BotInterface: # Resume > 5 мин: серия этой ссылки полностью отбрасывается SERIES_EXPIRY = 5 * 60 remaining = visits_count + successful_this_link = 0 paused_at: Optional[float] = None first_click = True @@ -2443,6 +2515,7 @@ class BotInterface: return if result.success: params.successful_visits += 1 + successful_this_link += 1 remaining -= 1 if params.user_id: new_balance = await self.balance_storage.deduct(params.user_id, 1) @@ -2470,6 +2543,15 @@ class BotInterface: if params.stopped: return + # Серия завершена — удаляем уведомление + if notif_msg: + try: + await message.bot.delete_message( + chat_id=message.chat.id, + message_id=notif_msg.message_id, + ) + except Exception: + pass except Exception as e: logger.error(f"Process URL error: {e}") @@ -3070,24 +3152,34 @@ class BotInterface: await self._send_temp(message, "Ошибка запуска задачи") async def _show_users_list(self, message: Message, edit: bool = False): - balances = await self.balance_storage.get_all() - all_tasks = await self.task_manager.get_all_tasks() + all_user_ids, all_tasks, all_clicks, all_rub = await asyncio.gather( + self.auth_storage.get_all_user_ids(), + self.task_manager.get_all_tasks(), + self.balance_storage.get_all(), + self.rub_storage.get_all(), + ) - text = "👥 Пользователи\n\n" + text = f"👥 Пользователи ({len(all_user_ids)})\n\n" builder = InlineKeyboardBuilder() - if not balances: - text += "Нет пользователей с балансом." + if not all_user_ids: + text += "Нет зарегистрированных пользователей." else: - for uid_str, balance in list(balances.items())[:15]: - uid = int(uid_str) + for uid in all_user_ids[:20]: + clicks = all_clicks.get(str(uid), 0) + rub = all_rub.get(str(uid), 0) active = sum( 1 for p in all_tasks.values() if p.user_id == uid and not p.completed and not p.stopped ) - text += f"👤 `{uid}` — 💰 {balance} | 📋 {active} задач\n" + bal_str = f"🖱{clicks}" + if rub: + bal_str += f" 🪙{rub}₽" + if active: + bal_str += f" 📋{active}" + text += f"👤 `{uid}` — {bal_str}\n" builder.row(InlineKeyboardButton( - text=f"👤 {uid} (💰{balance})", + text=f"👤 {uid} {bal_str}", callback_data=f"udetail_{uid}", )) @@ -3095,25 +3187,23 @@ class BotInterface: await self._edit_or_send(message, text, builder.as_markup(), edit) async def _show_user_detail(self, callback: CallbackQuery, target_user_id: int): - balance = await self.balance_storage.get_balance(target_user_id) + clicks = await self.balance_storage.get_balance(target_user_id) + rub = await self.rub_storage.get_balance(target_user_id) all_tasks = await self.task_manager.get_all_tasks() user_tasks = {tid: p for tid, p in all_tasks.items() if p.user_id == target_user_id} active = sum(1 for p in user_tasks.values() if not p.completed and not p.stopped) text = ( f"👤 Пользователь {target_user_id}\n\n" - f"🔢 Переходов: {balance}\n" - f"📋 Задач: {len(user_tasks)} (активных: {active})\n\n" - "Пополнить баланс:" + f"🖱 Переходов: {clicks}\n" + f"🪙 Рублей: {rub} ₽\n" + f"📋 Задач: {len(user_tasks)} (активных: {active})\n" ) builder = InlineKeyboardBuilder() - for amount in [10, 50, 100, 500]: - builder.button(text=f"+{amount}", callback_data=f"ubal_{amount}_{target_user_id}") - builder.adjust(4) - builder.row(InlineKeyboardButton( - text="✏️ Другая сумма", - callback_data=f"ubal_custom_{target_user_id}", - )) + + builder.row(InlineKeyboardButton(text="➕ Переходы", callback_data=f"ubal_menu_{target_user_id}")) + builder.row(InlineKeyboardButton(text="➕ Рубли", callback_data=f"urub_menu_{target_user_id}")) + if user_tasks: builder.row(InlineKeyboardButton( text=f"📋 Задачи ({len(user_tasks)})", diff --git a/managers/storage.py b/managers/storage.py index 9fbf9b7..7e959fb 100644 --- a/managers/storage.py +++ b/managers/storage.py @@ -344,6 +344,18 @@ class PaymentStorage: payments.pop(payment_id, None) await asyncio.to_thread(self._save_sync, payments) + async def pop(self, payment_id: str) -> Optional[dict]: + """Get and mark payment as processed. Returns None if not found or already processed.""" + async with self._lock: + payments = await asyncio.to_thread(self._load_sync) + data = payments.get(payment_id) + if data is None or data.get("processed"): + return None + payments[payment_id]["processed"] = True + payments[payment_id]["processed_at"] = datetime.now().isoformat(timespec="seconds") + await asyncio.to_thread(self._save_sync, payments) + return data + async def get_by_user(self, user_id: int) -> Optional[dict]: async with self._lock: payments = await asyncio.to_thread(self._load_sync) diff --git a/services/browser_service.py b/services/browser_service.py index 85c4d06..9808aa8 100644 --- a/services/browser_service.py +++ b/services/browser_service.py @@ -332,7 +332,7 @@ class BrowserService: current = new_url redirect = True task = asyncio.create_task( - page.wait_for_load_state("load", timeout=30000) + page.wait_for_load_state("domcontentloaded", timeout=30000) ) await self._move_mouse(page, task) await task diff --git a/services/gql_chat_service.py b/services/gql_chat_service.py index a1c77f1..6bc1986 100644 --- a/services/gql_chat_service.py +++ b/services/gql_chat_service.py @@ -5,6 +5,7 @@ Twitch чат через IRC over WebSocket (wss://irc-ws.chat.twitch.tv:443). import asyncio import logging +import random import re from typing import Optional, Callable, Awaitable, List from datetime import datetime @@ -34,7 +35,13 @@ class TwitchGQLChatClient: ): self.channel = channel.lower() self.target_username = target_username.lower() - self.irc_username = irc_username or settings.IRC_USERNAME + # Anonymous Twitch login (justinfanNNNNN) is shared across all clients by + # default, so parallel monitors on the same nick get kicked by Twitch + # (one connection per nick) — randomize it unless a real login is configured. + if irc_username or settings.IRC_USERNAME != "justinfan12345": + self.irc_username = irc_username or settings.IRC_USERNAME + else: + self.irc_username = f"justinfan{random.randint(10000, 99999)}" self.irc_oauth = irc_oauth or settings.IRC_OAUTH self._ws: Optional[aiohttp.ClientWebSocketResponse] = None @@ -257,6 +264,10 @@ class TwitchGQLChatClient: raise except Exception: pass + else: + logger.info( + f"🚫 Домен не разрешён: {url} (allowed_domains={allowed_domains})" + ) return stats diff --git a/services/irc_service.py b/services/irc_service.py index 74383b7..be402e6 100644 --- a/services/irc_service.py +++ b/services/irc_service.py @@ -3,6 +3,7 @@ IRC сервис для мониторинга Twitch чата. """ import asyncio +import random import re import logging from typing import Optional, Callable, Awaitable, List @@ -28,7 +29,13 @@ class TwitchIRCClient: ): self.channel = channel.lower() self.target_username = target_username.lower() - self.irc_username = irc_username or settings.IRC_USERNAME + # Anonymous Twitch login (justinfanNNNNN) is shared across all clients by + # default, so parallel monitors on the same nick get kicked by Twitch + # (one connection per nick) — randomize it unless a real login is configured. + if irc_username or settings.IRC_USERNAME != "justinfan12345": + self.irc_username = irc_username or settings.IRC_USERNAME + else: + self.irc_username = f"justinfan{random.randint(10000, 99999)}" self.irc_oauth = irc_oauth or settings.IRC_OAUTH self._reader: Optional[asyncio.StreamReader] = None diff --git a/services/webhook_server.py b/services/webhook_server.py index 2eabdc8..6fa25cb 100644 --- a/services/webhook_server.py +++ b/services/webhook_server.py @@ -11,6 +11,7 @@ import hashlib import json import logging from datetime import datetime +from pathlib import Path from typing import Optional from aiohttp import web @@ -74,6 +75,21 @@ class HelketWebhookServer: await self._runner.cleanup() logger.info("Heleket webhook server stopped") + async def _save_lost_webhook(self, payload: dict) -> None: + """Сохраняет необработанный вебхук в файл и уведомляет админа.""" + lost_file = Path("data/lost_webhooks.json") + try: + data = [] + if lost_file.exists(): + with open(lost_file, "r", encoding="utf-8") as f: + data = json.load(f) + data.append({**payload, "_saved_at": datetime.now().isoformat(timespec="seconds")}) + with open(lost_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"Lost webhook saved: uuid={payload.get('uuid')}") + except Exception as e: + logger.error(f"Failed to save lost webhook: {e}") + async def _handle_health(self, request: web.Request) -> web.Response: return web.Response(text="ok") @@ -102,9 +118,15 @@ class HelketWebhookServer: logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored") return web.Response(text="ok") - payment = await self._payment_storage.get(payment_uuid) + existing = await self._payment_storage.get(payment_uuid) + if existing and existing.get("processed"): + logger.info(f"Webhook: payment {payment_uuid} already processed — skipping duplicate") + return web.Response(text="ok") + + payment = await self._payment_storage.pop(payment_uuid) if not payment: - logger.warning(f"Webhook: payment {payment_uuid} not found (already processed?)") + logger.warning(f"Webhook: payment {payment_uuid} not found in storage") + await self._save_lost_webhook(payload) return web.Response(text="ok") user_id = payment.get("user_id") @@ -147,7 +169,6 @@ class HelketWebhookServer: } logger.info(f"Webhook: user={user_id} +{rub} RUB") - await self._payment_storage.delete(payment_uuid) await self._history_storage.add(user_id, history_record) if chat_id: