diff --git a/config/settings.py b/config/settings.py index 0221e4a..1542789 100644 --- a/config/settings.py +++ b/config/settings.py @@ -10,6 +10,14 @@ class Settings(BaseSettings): BOT_TOKEN: str = "" TELEGRAM_PROXY: str = "" # socks5://user:pass@host:port ADMIN_PASSWORD: str = "" + + # Heleket payment + HELEKET_API_KEY: str = "" + HELEKET_SHOP_ID: str = "" + CLICK_PRICE_RUB: float = 2.0 # Цена одного перехода в рублях + TOPUP_MIN_RUB: int = 50 # Минимальное пополнение ₽ + TOPUP_MAX_RUB: int = 50000 # Максимальное пополнение ₽ + CLICKS_MIN: int = 10 # Минимальная покупка переходов # Telethon/MTProto settings API_ID: int = 12345 diff --git a/handlers/commands.py b/handlers/commands.py index 791672f..11e5a5f 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -26,7 +26,8 @@ from services.browser_service import BrowserService from services.visit_service import VisitScheduler from utils.helpers import parse_range, format_range, extract_domain from utils.telegram import send_message_safe, send_visit_result -from managers.storage import TaskStorage, ChatStorage, BalanceStorage +from managers.storage import TaskStorage, ChatStorage, BalanceStorage, RubleBalanceStorage, PaymentStorage +from services.payment_service import HelketPayment from auth.manager import AuthManager from auth.storage import AuthStorage from auth import ADMIN_PASSWORD @@ -54,8 +55,12 @@ class BotInterface: self.chat_storage = chat_storage self.auth_manager = AuthManager() self.auth_storage = AuthStorage() - self.balance_storage = BalanceStorage() + self.balance_storage = BalanceStorage() # переходы + self.rub_storage = RubleBalanceStorage() # рубли + self.payment_storage = PaymentStorage() + self.heleket = HelketPayment() self._user_input_state: Dict[int, dict] = {} + self._payment_state: Dict[int, str] = {} # user_id -> "topup" | "buy_clicks" self._user_task_state: Dict[int, dict] = {} # шаги создания задачи пользователем self._admin_balance_state: Dict[int, int] = {} # admin_id -> target_user_id self._menu_msg: Dict[int, int] = {} # user_id -> inline keyboard message_id @@ -90,13 +95,11 @@ class BotInterface: exists = await interface.auth_storage.user_exists(user_id) if exists: - sent = await message.answer("👋 С возвращением!\n\nВведите пароль для входа:") + sent = await message.answer("С возвращением! Введите пароль:") interface._waiting_password[user_id] = "login" else: sent = await message.answer( - "👋 Добро пожаловать!\n\n" - "Вы здесь впервые. Придумайте пароль для регистрации\n" - "_(минимум 4 символа):_" + "Добро пожаловать!\n\nПридумайте пароль для входа (мин. 4 символа):" ) interface._waiting_password[user_id] = "register" interface._menu_msg[user_id] = sent.message_id @@ -167,10 +170,7 @@ class BotInterface: async def require_auth(event): user_id = event.from_user.id if not interface.auth_manager.is_authenticated(user_id): - await event.answer( - "🔒 Требуется авторизация!\n\n" - "Напишите администратору для получения доступа." - ) + await event.answer("Требуется авторизация") return False return True @@ -178,18 +178,10 @@ class BotInterface: async def require_admin(event): user_id = event.from_user.id if not interface.auth_manager.is_authenticated(user_id): - await event.answer( - "🔒 Требуется авторизация!\n\n" - "/register <пароль> — зарегистрироваться\n" - "/login <пароль> — войти" - ) + await event.answer("Требуется авторизация") return False if not interface.auth_manager.is_admin(user_id): - await event.answer( - "⛔ **Нет доступа!**\n\n" - "Эта функция доступна только администраторам.\n" - "Войдите как администратор." - ) + await event.answer("Нет доступа — войдите как администратор") return False return True @@ -278,13 +270,13 @@ class BotInterface: if not await require_admin(message): return await message.answer( - "🚀 **БЫСТРЫЙ СТАРТ**\n\n" - "**Twitch мониторинг:**\n" + "🚀 БЫСТРЫЙ СТАРТ\n\n" + "Twitch мониторинг:\n" "`<канал> [посещений] [минут] [задержка] [чтение] [домены]`\n\n" - "**Пример:** `streamer user123 5 30 10-30 10-30`\n\n" - "**Посещение URL:**\n" + "Пример: `streamer user123 5 30 10-30 10-30`\n\n" + "Посещение URL:\n" "` [задержка] [повторов] [чтение]`\n\n" - "**Пример:** `https://example.com 10-30 100 15-30`" + "Пример: `https://example.com 10-30 100 15-30`" ) # === Управление задачей === @@ -449,21 +441,117 @@ class BotInterface: # === Кабинет пользователя === @dp.callback_query(F.data == "user_menu") async def cb_user_menu(callback: CallbackQuery): - await interface._show_user_menu(callback.message, edit=True) + await interface._show_user_menu( + callback.message, edit=True, user_id=callback.from_user.id + ) await callback.answer() @dp.callback_query(F.data == "my_balance") async def cb_my_balance(callback: CallbackQuery): - uid = callback.from_user.id - balance = await interface.balance_storage.get_balance(uid) - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_menu")) - await callback.message.edit_text( - f"💰 **Ваш баланс**\n\n**{balance}** переходов", - reply_markup=builder.as_markup() + await interface._show_user_menu( + callback.message, edit=True, user_id=callback.from_user.id ) await callback.answer() + # ── Пополнение рублей (крипта → рубли) ────────────────────────────── + + @dp.callback_query(F.data == "topup_main") + async def cb_topup_main(callback: CallbackQuery): + uid = callback.from_user.id + existing = await interface.payment_storage.get_by_user(uid) + if existing: + await callback.answer("⚠️ Уже есть незакрытый счёт", show_alert=True) + await interface._show_invoice(callback, existing) + return + interface._payment_state[uid] = "topup" + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_pay_input")) + await callback.message.edit_text( + f"💳 ПОПОЛНЕНИЕ БАЛАНСА\n\n" + f"Введите сумму пополнения в рублях:\n" + f"(от {settings.TOPUP_MIN_RUB} до {settings.TOPUP_MAX_RUB} ₽)", + reply_markup=builder.as_markup() + ) + interface._menu_msg[uid] = callback.message.message_id + await callback.answer() + + @dp.callback_query(F.data == "check_pay") + async def cb_check_payment(callback: CallbackQuery): + uid = callback.from_user.id + payment = await interface.payment_storage.get_by_user(uid) + if not payment: + await callback.answer("❌ Платёж не найден", show_alert=True) + await interface._show_user_menu(callback.message, edit=True, user_id=callback.from_user.id) + return + + await callback.answer("⏳ Проверяем...") + status = await interface.heleket.check_status(payment["payment_id"]) + + if status == "paid": + await interface.payment_storage.delete(payment["payment_id"]) + new_rub = await interface.rub_storage.add_balance(uid, payment["rub"]) + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop")) + builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance")) + await callback.message.edit_text( + f"✅ Оплата подтверждена!\n\n" + f"Начислено: +{payment['rub']} ₽\n" + f"Баланс: {new_rub} ₽", + reply_markup=builder.as_markup() + ) + elif status == "expired": + await interface.payment_storage.delete(payment["payment_id"]) + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="💳 Пополнить снова", callback_data="topup_main")) + builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance")) + await callback.message.edit_text( + "Счёт истёк. Создайте новый.", + reply_markup=builder.as_markup() + ) + else: + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🔄 Проверить снова", callback_data="check_pay")) + builder.row(InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_pay")) + await callback.message.edit_text( + "⏳ Платёж ещё не поступил\n\nОбычно перевод занимает 1–5 минут.", + reply_markup=builder.as_markup() + ) + + @dp.callback_query(F.data == "cancel_pay") + async def cb_cancel_payment(callback: CallbackQuery): + uid = callback.from_user.id + payment = await interface.payment_storage.get_by_user(uid) + if payment: + await interface.payment_storage.delete(payment["payment_id"]) + await callback.answer("Счёт отменён") + await interface._show_user_menu(callback.message, edit=True, user_id=callback.from_user.id) + + # ── Покупка переходов (рубли → переходы) ──────────────────────────── + + @dp.callback_query(F.data == "visit_shop") + async def cb_visit_shop(callback: CallbackQuery): + uid = callback.from_user.id + rub_bal = await interface.rub_storage.get_balance(uid) + price = settings.CLICK_PRICE_RUB + interface._payment_state[uid] = "buy_clicks" + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_pay_input")) + await callback.message.edit_text( + f"🛒 Купить переходы\n\n" + f"Баланс: {rub_bal} ₽\n" + f"Цена: {price:.0f} ₽ за 1 переход (мин. {settings.CLICKS_MIN})\n\n" + f"Введите количество:", + reply_markup=builder.as_markup() + ) + interface._menu_msg[uid] = callback.message.message_id + await callback.answer() + + @dp.callback_query(F.data == "cancel_pay_input") + async def cb_cancel_pay_input(callback: CallbackQuery): + uid = callback.from_user.id + interface._payment_state.pop(uid, None) + await interface._show_user_menu(callback.message, edit=True, user_id=callback.from_user.id) + @dp.callback_query(F.data == "new_task") async def cb_new_task(callback: CallbackQuery): uid = callback.from_user.id @@ -483,9 +571,9 @@ class BotInterface: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_new_task")) await callback.message.edit_text( - f"📝 **НОВАЯ ЗАДАЧА**\n\n" - f"💰 Баланс: {balance} переходов\n\n" - "Шаг 1/2: Введите название вашего Twitch канала:", + f"📝 Новая задача\n\n" + f"Переходов: {balance}\n\n" + "Шаг 1/2 — введите название Twitch канала:", reply_markup=builder.as_markup() ) interface._menu_msg[uid] = callback.message.message_id @@ -503,6 +591,7 @@ class BotInterface: builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="user_menu")) await callback.message.edit_text( "🔑 Введите пароль администратора:", + reply_markup=builder.as_markup() ) await callback.answer() @@ -514,13 +603,14 @@ class BotInterface: await callback.answer("❌ Требуется авторизация", show_alert=True) return interface.auth_manager.login(uid, is_admin=False) - await interface._show_user_menu(callback.message) + await interface._show_user_menu(callback.message, user_id=uid) await callback.answer("👤 Переключились в режим пользователя") @dp.callback_query(F.data == "cancel_new_task") async def cb_cancel_new_task(callback: CallbackQuery): - interface._user_task_state.pop(callback.from_user.id, None) - await interface._show_user_menu(callback.message) + uid = callback.from_user.id + interface._user_task_state.pop(uid, None) + await interface._show_user_menu(callback.message, user_id=uid) await callback.answer("Отменено") @dp.callback_query(F.data == "my_tasks") @@ -583,8 +673,8 @@ class BotInterface: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="my_tasks")) await callback.message.edit_text( - f"🌐 **Изменить домены**\n\n" - f"Текущие: `{current}`\n\n" + f"🌐 Изменить домены\n\n" + f"Текущие: {current}\n\n" "Введите новые домены через запятую:", reply_markup=builder.as_markup() ) @@ -651,7 +741,7 @@ class BotInterface: if mode == "register": if len(text) < 4: - sent = await message.answer("❌ Пароль слишком короткий (минимум 4 символа). Попробуйте ещё раз:") + sent = await message.answer("Пароль слишком короткий, мин. 4 символа:") interface._menu_msg[user_id] = sent.message_id interface._waiting_password[user_id] = "register" return @@ -668,10 +758,10 @@ class BotInterface: elif mode == "login": user_data = await interface.auth_storage.get_user(user_id) if not user_data: - await interface._send_temp(message, "❌ Вы не зарегистрированы.\nНапишите администратору.") + await interface._send_temp(message, "Вы не зарегистрированы") return if interface.auth_manager.is_locked_out(user_id): - await interface._send_temp(message, "🔒 Слишком много попыток. Попробуйте позже.") + await interface._send_temp(message, "Слишком много попыток, попробуйте позже") return if text == ADMIN_PASSWORD: interface.auth_manager.login(user_id, is_admin=True) @@ -684,7 +774,7 @@ class BotInterface: await interface._show_user_menu(message) else: interface.auth_manager.record_failed_attempt(user_id) - sent = await message.answer("❌ Неверный пароль. Попробуйте ещё раз:") + sent = await message.answer("Неверный пароль, попробуйте ещё раз:") interface._menu_msg[user_id] = sent.message_id interface._waiting_password[user_id] = "login" @@ -694,7 +784,7 @@ class BotInterface: await interface._update_commands() await interface._show_main_menu(message) else: - sent = await message.answer("❌ Неверный пароль администратора. Попробуйте ещё раз:") + sent = await message.answer("Неверный пароль администратора:") interface._menu_msg[user_id] = sent.message_id interface._waiting_password[user_id] = "admin_upgrade" return @@ -704,11 +794,13 @@ class BotInterface: await interface._process_user_task_input(message) return + # Ввод суммы пополнения или покупки переходов + if user_id in interface._payment_state: + await interface._process_payment_input(message) + return + if not interface.auth_manager.is_authenticated(user_id): - await interface._send_temp( - message, - "🔒 Требуется авторизация!\n\n/register <пароль> — зарегистрироваться\n/login <пароль> — войти" - ) + await interface._send_temp(message, "Требуется авторизация") return if not interface.auth_manager.is_admin(user_id): @@ -806,7 +898,7 @@ class BotInterface: await self.chat_storage.add_streamer(message.chat.id, channel) domains_str = ", ".join(domains) if domains else "все" text = ( - f"✅ **МОНИТОРИНГ ЗАПУЩЕН**\n\n" + f"✅ Мониторинг запущен\n\n" f"📺 Канал: `{channel}`\n" f"👤 Отслеживание: все пользователи\n" f"🔗 На ссылку: {vl} визитов\n" @@ -878,7 +970,7 @@ class BotInterface: if success: await message.answer( - f"✅ **ПОСЕЩЕНИЕ ЗАПУЩЕНО**\n\n" + f"✅ Посещение запущено\n\n" f"🌐 `{url}`\n" f"⏱️ Задержка: {format_range(min_delay, max_delay)}с\n" f"📖 Чтение: {format_range(min_reading, max_reading)}с\n" @@ -934,10 +1026,10 @@ class BotInterface: stats = await self.task_manager.get_stats() text = ( - "👋 **ГЛАВНОЕ МЕНЮ**\n\n" - f"📺 Twitch: **{stats['twitch']}**\n" - f"🌐 Визитов: **{stats['visit']}**\n" - f"⏸️ Пауза: **{stats['paused']}**\n\n" + "👋 Главное меню\n\n" + f"📺 Twitch: {stats['twitch']}\n" + f"🌐 Визитов: {stats['visit']}\n" + f"⏸️ Пауза: {stats['paused']}\n\n" "Выберите раздел:" ) @@ -970,21 +1062,21 @@ class BotInterface: twitch_done = {tid: p for tid, p in completed.items() if p.task_type == "twitch_irc"} if not twitch_active and not twitch_done: - text = "📺 **СТРИМЕРЫ**\n\nНет задач.\nОтправьте `<канал>` для добавления." + text = "📺 Стримеры\n\nЗадач нет. Отправьте название канала для добавления." builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) else: - text = "📺 **СТРИМЕРЫ**\n\n" + text = "📺 Стримеры\n\n" builder = InlineKeyboardBuilder() if twitch_active: - text += "**🔄 Активные:**\n" + text += "🔄 Активные:\n" for tid, p in list(twitch_active.items())[:10]: emoji = p.get_status_emoji() domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" text += ( - f"{emoji} **{p.channel}**\n" + f"{emoji} {p.channel}\n" f" 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" ) builder.row(InlineKeyboardButton( @@ -993,13 +1085,13 @@ class BotInterface: )) if twitch_done: - text += "\n**📁 Завершенные:**\n" + text += "\n📁 Завершенные:\n" for tid, p in list(twitch_done.items())[:5]: emoji = p.get_status_emoji() domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" text += ( - f"{emoji} **{p.channel}**\n" + f"{emoji} {p.channel}\n" f" 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" ) builder.row(InlineKeyboardButton( @@ -1024,11 +1116,11 @@ class BotInterface: stats = await self.task_manager.get_stats() proxy_stats = self.proxy_manager.get_stats() text = ( - "📈 **СТАТУС**\n\n" - f"📺 Twitch: **{stats['twitch']}**\n" - f"🌐 Визиты: **{stats['visit']}**\n" - f"⏸️ Пауза: **{stats['paused']}**\n" - f"🔄 Активно: **{stats['active']}**\n\n" + "📈 Статус\n\n" + f"📺 Twitch: {stats['twitch']}\n" + f"🌐 Визиты: {stats['visit']}\n" + f"⏸️ Пауза: {stats['paused']}\n" + f"🔄 Активно: {stats['active']}\n\n" f"🔌 Прокси: {proxy_stats['total']} всего, {proxy_stats['available']} доступно" ) builder = InlineKeyboardBuilder() @@ -1046,7 +1138,7 @@ class BotInterface: await callback.answer("❌ Задача не найдена") return - text = f"📋 **УПРАВЛЕНИЕ**\n\n{params.get_stats_text()}" + text = f"📋 Управление\n\n{params.get_stats_text()}" builder = InlineKeyboardBuilder() @@ -1250,31 +1342,48 @@ class BotInterface: # КАБИНЕТ ПОЛЬЗОВАТЕЛЯ # ========================================================================= - async def _show_user_menu(self, message: Message, edit: bool = False): - user_id = message.from_user.id + async def _show_user_menu(self, message: Message, edit: bool = False, user_id: int = None): + if user_id is None: + user_id = message.from_user.id if message.from_user else None + if not user_id: + return # Сбрасываем все незавершённые состояния при возврате в меню self._waiting_password.pop(user_id, None) self._user_task_state.pop(user_id, None) - balance = await self.balance_storage.get_balance(user_id) + self._payment_state.pop(user_id, None) + + visits = await self.balance_storage.get_balance(user_id) + rubles = await self.rub_storage.get_balance(user_id) all_tasks = await self.task_manager.get_all_tasks() active = sum( 1 for p in all_tasks.values() if p.user_id == user_id and not p.completed and not p.stopped ) + pending = await self.payment_storage.get_by_user(user_id) + text = ( - "👤 **ЛИЧНЫЙ КАБИНЕТ**\n\n" - f"💰 Баланс: **{balance}** переходов\n" - f"📋 Активных задач: **{active}**\n\n" - "Выберите действие:" + "👤 Личный кабинет\n\n" + f"🪙 Рублей: {rubles} ₽\n" + f"🔢 Переходов: {visits}\n" + f"📋 Активных задач: {active}" ) + if pending: + text += "\n\n⏳ Есть незакрытый счёт" + builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton(text="📝 Новая задача", callback_data="new_task"), InlineKeyboardButton(text="📋 Мои задачи", callback_data="my_tasks"), ) - builder.row(InlineKeyboardButton(text="💰 Баланс", callback_data="my_balance")) + if pending: + builder.row(InlineKeyboardButton(text="⏳ Проверить оплату", callback_data="check_pay")) + else: + builder.row( + InlineKeyboardButton(text="💳 Пополнить", callback_data="topup_main"), + InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop"), + ) builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode")) - await self._edit_or_send(message, text, builder.as_markup(), edit) + await self._edit_or_send(message, text, builder.as_markup(), edit, user_id=user_id) async def _show_user_tasks( self, message: Message, user_id: int, @@ -1284,9 +1393,9 @@ class BotInterface: user_tasks = {tid: p for tid, p in all_tasks.items() if p.user_id == user_id} if not user_tasks: - text = f"📋 **ЗАДАЧИ** {'пользователя `' + str(user_id) + '`' if for_admin else ''}\n\nЗадач нет." + text = f"📋 Задачи {str(user_id) if for_admin else ''}\n\nЗадач нет." else: - header = f"📋 **ЗАДАЧИ** `{user_id}`\n\n" if for_admin else "📋 **МОИ ЗАДАЧИ**\n\n" + header = f"📋 Задачи пользователя {user_id}\n\n" if for_admin else "📋 Мои задачи\n\n" text = header for tid, p in list(user_tasks.items())[:10]: domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" @@ -1355,7 +1464,7 @@ class BotInterface: sent = await message.answer( f"📺 Канал: `{channel}`\n\n" "Шаг 2/2: Введите домены через запятую:\n" - "_Пример: `twitch.tv, youtube.com`_", + "Пример: twitch.tv, youtube.com", reply_markup=builder.as_markup() ) self._menu_msg[user_id] = sent.message_id @@ -1437,11 +1546,159 @@ class BotInterface: # ПАНЕЛЬ ПОЛЬЗОВАТЕЛЕЙ (ADMIN) # ========================================================================= + # ========================================================================= + # КАБИНЕТ / МАГАЗИН + # ========================================================================= + + async def _process_payment_input(self, message: Message): + user_id = message.from_user.id + action = self._payment_state.pop(user_id) + await self._safe_delete(message.bot, message.chat.id, message.message_id) + + text = message.text.strip() if message.text else "" + prompt_id = self._menu_msg.pop(user_id, None) + + async def _edit_prompt(new_text: str, markup=None): + if prompt_id: + try: + await message.bot.edit_message_text( + chat_id=message.chat.id, message_id=prompt_id, + text=new_text, reply_markup=markup + ) + return + except Exception: + pass + await message.answer(new_text, reply_markup=markup) + + try: + value = int(text) + except (ValueError, TypeError): + await self._send_temp(message, "❌ Введите целое число") + self._payment_state[user_id] = action + if prompt_id: + self._menu_msg[user_id] = prompt_id + return + + if action == "topup": + if value < settings.TOPUP_MIN_RUB: + await self._send_temp(message, f"❌ Минимум {settings.TOPUP_MIN_RUB} ₽") + self._payment_state[user_id] = "topup" + if prompt_id: + self._menu_msg[user_id] = prompt_id + return + if value > settings.TOPUP_MAX_RUB: + await self._send_temp(message, f"❌ Максимум {settings.TOPUP_MAX_RUB} ₽") + self._payment_state[user_id] = "topup" + if prompt_id: + self._menu_msg[user_id] = prompt_id + return + + order_id = f"{user_id}_topup_{int(datetime.now().timestamp())}" + invoice = await self.heleket.create_invoice(value, order_id) + if not invoice: + await self._send_temp(message, "❌ Ошибка создания счёта. Попробуйте позже.") + return + + payment_data = { + "user_id": user_id, + "payment_id": invoice["payment_id"], + "rub": value, + "address": invoice.get("address"), + "url": invoice.get("url"), + "mock": invoice.get("mock", False), + "created_at": datetime.now().isoformat(), + } + await self.payment_storage.save(invoice["payment_id"], payment_data) + + builder = InlineKeyboardBuilder() + if invoice.get("url"): + builder.row(InlineKeyboardButton(text="💳 Оплатить через Heleket", url=invoice["url"])) + builder.row(InlineKeyboardButton(text="✅ Я оплатил", callback_data="check_pay")) + builder.row(InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_pay")) + + if payment_data["mock"]: + body = "⚠️ Тестовый режим — добавьте HELEKET_API_KEY в .env" + elif invoice.get("url"): + body = "Нажмите кнопку ниже для оплаты" + else: + body = f"Адрес:\n`{invoice['address']}`" + + await _edit_prompt( + f"💳 Счёт на оплату\n\n" + f"💰 Сумма: {value} ₽\n\n" + f"{body}\n\n" + f"⏱ Счёт действителен 60 минут", + builder.as_markup() + ) + + elif action == "buy_clicks": + if value < settings.CLICKS_MIN: + await self._send_temp(message, f"❌ Минимум {settings.CLICKS_MIN} переходов") + self._payment_state[user_id] = "buy_clicks" + if prompt_id: + self._menu_msg[user_id] = prompt_id + return + + cost = int(value * settings.CLICK_PRICE_RUB) + rub_bal = await self.rub_storage.get_balance(user_id) + + if rub_bal < cost: + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="💳 Пополнить рубли", callback_data="topup_main")) + builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance")) + await _edit_prompt( + f"Недостаточно рублей\n\nНужно: {cost} ₽, у вас: {rub_bal} ₽", + builder.as_markup() + ) + return + + new_rub = await self.rub_storage.deduct(user_id, cost) + new_visits = await self.balance_storage.add_balance(user_id, value) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🛒 Купить ещё", callback_data="visit_shop")) + builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance")) + await _edit_prompt( + f"✅ Переходы начислены\n\n" + f"+{value} переходов за {cost} ₽\n" + f"Остаток: {new_rub} ₽ | Переходов: {new_visits}", + builder.as_markup() + ) + + + + async def _show_invoice(self, callback: CallbackQuery, payment: dict): + """Отображает счёт на оплату.""" + builder = InlineKeyboardBuilder() + if payment.get("url"): + builder.row(InlineKeyboardButton(text="💳 Оплатить через Heleket", url=payment["url"])) + builder.row(InlineKeyboardButton(text="✅ Я оплатил", callback_data="check_pay")) + builder.row(InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_pay")) + + if payment.get("mock"): + body = "⚠️ Тестовый режим — добавьте HELEKET_API_KEY и HELEKET_SHOP_ID в .env" + elif payment.get("url"): + body = f"Нажмите кнопку ниже для оплаты через Heleket" + else: + body = f"Адрес для оплаты:\n`{payment['address']}`" + + await callback.message.edit_text( + f"💳 Счёт на оплату\n\n" + f"💰 Сумма: {payment['rub']} ₽\n\n" + f"{body}\n\n" + f"⏱ Счёт действителен 60 минут", + reply_markup=builder.as_markup() + ) + + # ========================================================================= + # ПАНЕЛЬ ПОЛЬЗОВАТЕЛЕЙ (ADMIN) + # ========================================================================= + 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() - text = "👥 **ПОЛЬЗОВАТЕЛИ**\n\n" + text = "👥 Пользователи\n\n" builder = InlineKeyboardBuilder() if not balances: @@ -1469,8 +1726,8 @@ class BotInterface: 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"👤 Пользователь {target_user_id}\n\n" + f"🔢 Переходов: {balance}\n" f"📋 Задач: {len(user_tasks)} (активных: {active})\n\n" "Пополнить баланс:" ) diff --git a/managers/storage.py b/managers/storage.py index eb0ec13..f4405ec 100644 --- a/managers/storage.py +++ b/managers/storage.py @@ -7,7 +7,7 @@ import json import logging from pathlib import Path from datetime import datetime -from typing import Dict +from typing import Dict, Optional from managers.task_manager import TaskParams @@ -173,10 +173,10 @@ class ChatStorage: await asyncio.to_thread(self._save_sync, data) -class BalanceStorage: - """Баланс переходов пользователей.""" +class _IntBalanceStorage: + """Базовое хранилище числового баланса пользователей.""" - def __init__(self, file_path: str = "data/balances.json"): + def __init__(self, file_path: str): self.file_path = Path(file_path) self.file_path.parent.mkdir(parents=True, exist_ok=True) self._lock = asyncio.Lock() @@ -188,7 +188,7 @@ class BalanceStorage: with open(self.file_path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, IOError) as e: - logger.warning(f"Failed to load balances: {e}") + logger.warning(f"Failed to load {self.file_path}: {e}") return {} def _save_sync(self, data: dict) -> None: @@ -207,7 +207,7 @@ class BalanceStorage: await asyncio.to_thread(self._save_sync, data) return data[str(user_id)] - async def deduct(self, user_id: int, amount: int = 1) -> int: + async def deduct(self, user_id: int, amount: int) -> int: async with self._lock: data = await asyncio.to_thread(self._load_sync) new_bal = max(0, data.get(str(user_id), 0) - amount) @@ -218,3 +218,67 @@ class BalanceStorage: async def get_all(self) -> dict: async with self._lock: return await asyncio.to_thread(self._load_sync) + + +class BalanceStorage(_IntBalanceStorage): + """Баланс переходов пользователей.""" + + def __init__(self, file_path: str = "data/balances.json"): + super().__init__(file_path) + + +class RubleBalanceStorage(_IntBalanceStorage): + """Рублёвый баланс пользователей.""" + + def __init__(self, file_path: str = "data/rub_balances.json"): + super().__init__(file_path) + + + + + +class PaymentStorage: + """Хранилище ожидающих платежей.""" + + def __init__(self, file_path: str = "data/payments.json"): + self.file_path = Path(file_path) + self.file_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = asyncio.Lock() + + def _load_sync(self) -> dict: + if not self.file_path.exists(): + return {} + try: + with open(self.file_path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + def _save_sync(self, data: dict) -> None: + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + async def save(self, payment_id: str, data: dict) -> None: + async with self._lock: + payments = await asyncio.to_thread(self._load_sync) + payments[payment_id] = data + await asyncio.to_thread(self._save_sync, payments) + + async def get(self, payment_id: str) -> Optional[dict]: + async with self._lock: + payments = await asyncio.to_thread(self._load_sync) + return payments.get(payment_id) + + async def delete(self, payment_id: str) -> None: + async with self._lock: + payments = await asyncio.to_thread(self._load_sync) + payments.pop(payment_id, None) + await asyncio.to_thread(self._save_sync, payments) + + async def get_by_user(self, user_id: int) -> Optional[dict]: + async with self._lock: + payments = await asyncio.to_thread(self._load_sync) + for pid, data in payments.items(): + if data.get("user_id") == user_id: + return {**data, "payment_id": pid} + return None diff --git a/services/payment_service.py b/services/payment_service.py new file mode 100644 index 0000000..d0d0198 --- /dev/null +++ b/services/payment_service.py @@ -0,0 +1,90 @@ +"""Heleket payment integration.""" + +import logging +from typing import Optional +import aiohttp + +from config.settings import settings + +logger = logging.getLogger(__name__) + + +class HelketPayment: + """Heleket payment gateway. Принимает сумму в рублях напрямую.""" + + BASE_URL = "https://api.heleket.com/v1" + + def __init__(self): + self.api_key = settings.HELEKET_API_KEY + self.shop_id = settings.HELEKET_SHOP_ID + self._mock = not (self.api_key and self.shop_id) + + async def create_invoice(self, amount_rub: int, order_id: str) -> Optional[dict]: + """ + Создаёт счёт на сумму в рублях. + Возвращает dict: payment_id, address, url. None при ошибке. + """ + if self._mock: + return { + "payment_id": f"mock_{order_id}", + "address": None, + "url": None, + "mock": True, + } + + try: + async with aiohttp.ClientSession() as session: + payload = { + "shop_id": self.shop_id, + "amount": str(amount_rub), + "currency": "RUB", + "order_id": order_id, + "url_return": "", + "url_callback": "", + } + headers = {"merchant": self.api_key, "Content-Type": "application/json"} + async with session.post( + f"{self.BASE_URL}/payment", + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + data = await resp.json() + if data.get("state") == 0: + r = data.get("result", {}) + return { + "payment_id": r.get("uuid"), + "address": r.get("address"), + "url": r.get("url"), + "mock": False, + } + logger.error(f"Heleket create error: {data}") + return None + except Exception as e: + logger.error(f"Heleket request error: {e}") + return None + + async def check_status(self, payment_id: str) -> str: + """Возвращает: 'paid' | 'pending' | 'expired' | 'error'""" + if payment_id.startswith("mock_"): + return "pending" + try: + async with aiohttp.ClientSession() as session: + headers = {"merchant": self.api_key} + async with session.get( + f"{self.BASE_URL}/payment/info/{payment_id}", + headers=headers, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + data = await resp.json() + if data.get("state") == 0: + status = data.get("result", {}).get("status", "") + if status == "paid": + return "paid" + if status in ("cancel", "expired"): + return "expired" + return "pending" + return "error" + except Exception as e: + logger.error(f"Heleket check error: {e}") + return "error"