diff --git a/handlers/commands.py b/handlers/commands.py index 8063704..a1a01d1 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -29,7 +29,7 @@ from services.browser_pool import BrowserPool 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, RubleBalanceStorage, PaymentStorage +from managers.storage import TaskStorage, ChatStorage, BalanceStorage, RubleBalanceStorage, PaymentStorage, PaymentHistoryStorage from services.payment_service import HelketPayment from services.twitch_api import get_viewer_count from auth.manager import AuthManager @@ -64,6 +64,7 @@ class BotInterface: self.balance_storage = BalanceStorage() # переходы self.rub_storage = RubleBalanceStorage() # рубли self.payment_storage = PaymentStorage() + self.payment_history = PaymentHistoryStorage() self.heleket = HelketPayment() self._user_input_state: Dict[int, dict] = {} self._payment_state: Dict[int, str] = {} # user_id -> "topup" | "buy_clicks" @@ -542,6 +543,28 @@ class BotInterface: ) await callback.answer() + @dp.callback_query(F.data == "payment_history") + async def cb_payment_history(callback: CallbackQuery): + uid = callback.from_user.id + history = await interface.payment_history.get_user_history(uid) + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance")) + + if not history: + text = "🧾 История платежей\n\nПлатежей пока нет." + else: + lines = ["🧾 История платежей\n"] + for r in history[:10]: + dt = r.get("at", "")[:16].replace("T", " ") + if r.get("type") == "clicks": + lines.append(f"🖱 {r['clicks']} переходов — {r['rub']} ₽ ({dt})") + else: + lines.append(f"💳 Пополнение {r['rub']} ₽ ({dt})") + text = "\n".join(lines) + + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + await callback.answer() + # ── Пополнение рублей (крипта → рубли) ────────────────────────────── _TOPUP_PRESETS = [100, 300, 500, 1000, 3000] @@ -613,6 +636,7 @@ class BotInterface: payment_data = { "user_id": uid, "chat_id": callback.message.chat.id, + "msg_id": callback.message.message_id, "payment_id": invoice["payment_id"], "rub": amount, "address": invoice.get("address"), @@ -925,6 +949,7 @@ class BotInterface: payment_data = { "user_id": uid, "chat_id": callback.message.chat.id, + "msg_id": callback.message.message_id, "payment_id": invoice["payment_id"], "clicks": clicks, "rub": rub, @@ -2483,6 +2508,7 @@ class BotInterface: InlineKeyboardButton(text="💳 Пополнить криптой", callback_data="topup_main"), InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop"), ) + builder.row(InlineKeyboardButton(text="🧾 История платежей", callback_data="payment_history")) builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode")) await self._edit_or_send(message, text, builder.as_markup(), edit, user_id=user_id) diff --git a/main.py b/main.py index 5ca0147..b3688fa 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ from services.browser_service import BrowserService from services.browser_pool import BrowserPool from services.webhook_server import HelketWebhookServer from handlers.commands import BotInterface -from managers.storage import TaskStorage, ChatStorage +from managers.storage import TaskStorage, ChatStorage, PaymentHistoryStorage logger = setup_logger(__name__) @@ -50,6 +50,7 @@ class BotApplication: self.browser_pool = BrowserPool(settings.BROWSER_POOL_SIZE, self.browser_service) self.storage = TaskStorage() self.chat_storage = ChatStorage() + self.payment_history = PaymentHistoryStorage() self.webhook_server: HelketWebhookServer = None # создаём после инициализации bot self.interface = BotInterface( @@ -97,6 +98,7 @@ class BotApplication: payment_storage=self.interface.payment_storage, balance_storage=self.interface.balance_storage, rub_storage=self.interface.rub_storage, + history_storage=self.payment_history, bot=self.bot, port=settings.HELEKET_WEBHOOK_PORT, ) diff --git a/managers/storage.py b/managers/storage.py index 630e41b..fe1506e 100644 --- a/managers/storage.py +++ b/managers/storage.py @@ -341,3 +341,41 @@ class PaymentStorage: if data.get("user_id") == user_id: return {**data, "payment_id": pid} return None + + +class PaymentHistoryStorage: + """История завершённых платежей (последние N на пользователя).""" + + MAX_PER_USER = 20 + + def __init__(self, file_path: str = "data/payment_history.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 add(self, user_id: int, record: dict) -> None: + async with self._lock: + data = await asyncio.to_thread(self._load_sync) + key = str(user_id) + history = data.get(key, []) + history.append(record) + data[key] = history[-self.MAX_PER_USER:] + await asyncio.to_thread(self._save_sync, data) + + async def get_user_history(self, user_id: int) -> list: + async with self._lock: + data = await asyncio.to_thread(self._load_sync) + return list(reversed(data.get(str(user_id), []))) diff --git a/services/webhook_server.py b/services/webhook_server.py index 6a0fea7..2eabdc8 100644 --- a/services/webhook_server.py +++ b/services/webhook_server.py @@ -10,16 +10,16 @@ import base64 import hashlib import json import logging +from datetime import datetime from typing import Optional from aiohttp import web from config.settings import settings -from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage +from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage, PaymentHistoryStorage logger = logging.getLogger(__name__) -# Доверенный IP Heleket из документации HELEKET_IP = "31.133.220.8" @@ -27,36 +27,32 @@ def _verify_sign(payload: dict, api_key: str) -> bool: """Верифицирует подпись webhook-запроса от Heleket. Алгоритм: md5( base64( json(payload_without_sign) ) + api_key ) - - ВАЖНО: PHP json_encode() экранирует '/' как '\/', Python — нет. - Heleket вычисляет подпись на PHP, поэтому вручную экранируем слэши. + PHP json_encode() экранирует '/' как '\/', Python — нет, делаем вручную. """ received_sign = payload.get("sign", "") data = {k: v for k, v in payload.items() if k != "sign"} data_json = json.dumps(data, separators=(",", ":"), ensure_ascii=False) - data_json = data_json.replace("/", "\\/") # имитируем PHP json_encode + data_json = data_json.replace("/", "\\/") data_b64 = base64.b64encode(data_json.encode("utf-8")).decode("utf-8") expected = hashlib.md5((data_b64 + api_key).encode("utf-8")).hexdigest() return expected == received_sign class HelketWebhookServer: - """ - aiohttp сервер для вебхуков Heleket. - Запускается параллельно с Telegram-ботом. - """ def __init__( self, payment_storage: PaymentStorage, balance_storage: BalanceStorage, rub_storage: RubleBalanceStorage, - bot, # aiogram Bot + history_storage: PaymentHistoryStorage, + bot, port: int = 8080, ): self._payment_storage = payment_storage self._balance_storage = balance_storage self._rub_storage = rub_storage + self._history_storage = history_storage self._bot = bot self._port = port self._app = web.Application() @@ -67,7 +63,7 @@ class HelketWebhookServer: self._app.router.add_get("/health", self._handle_health) async def start(self) -> None: - self._runner = web.AppRunner(self._app) + self._runner = web.AppRunner(self._app, access_log=None) await self._runner.setup() site = web.TCPSite(self._runner, "0.0.0.0", self._port) await site.start() @@ -82,7 +78,6 @@ class HelketWebhookServer: return web.Response(text="ok") async def _handle_webhook(self, request: web.Request) -> web.Response: - # Проверяем IP (опционально — может быть за прокси/CDN) client_ip = request.headers.get("X-Forwarded-For", request.remote) if settings.HELEKET_WHITELIST_IP and client_ip and HELEKET_IP not in client_ip: logger.warning(f"Webhook from unknown IP: {client_ip}") @@ -94,9 +89,8 @@ class HelketWebhookServer: logger.warning("Webhook: invalid JSON body") return web.Response(status=400, text="Bad Request") - logger.debug(f"Webhook payload: {payload}") + logger.info(f"Webhook received: uuid={payload.get('uuid')} status={payload.get('status')}") - # Верифицируем подпись если есть API ключ if self._api_key and not _verify_sign(payload, self._api_key): logger.warning(f"Webhook: invalid signature. uuid={payload.get('uuid')}") return web.Response(status=400, text="Invalid signature") @@ -104,16 +98,14 @@ class HelketWebhookServer: status = payload.get("status", "") payment_uuid = payload.get("uuid", "") - # paid_over — клиент заплатил больше суммы счёта, тоже засчитываем if status not in ("paid", "paid_over"): logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored") return web.Response(text="ok") - # Ищем платёж в хранилище payment = await self._payment_storage.get(payment_uuid) if not payment: - logger.warning(f"Webhook: payment {payment_uuid} not found in storage") - return web.Response(text="ok") # уже обработан или неизвестный + logger.warning(f"Webhook: payment {payment_uuid} not found (already processed?)") + return web.Response(text="ok") user_id = payment.get("user_id") chat_id = payment.get("chat_id") or user_id @@ -122,43 +114,69 @@ class HelketWebhookServer: try: if clicks: - # Прямая покупка переходов + # Прямая покупка переходов — начисляем переходы await self._balance_storage.add_balance(user_id, clicks) - added_clicks = clicks msg = ( f"✅ Оплата получена!\n\n" f"💳 Сумма: {rub} ₽\n" - f"🖱 Начислено: {clicks} переходов\n" - f"📊 Проверьте баланс в личном кабинете" + f"🖱 Начислено: {clicks} переходов" ) + history_record = { + "type": "clicks", + "rub": rub, + "clicks": clicks, + "uuid": payment_uuid, + "at": datetime.now().isoformat(timespec="seconds"), + } + logger.info(f"Webhook: user={user_id} +{clicks} clicks") else: - # Пополнение рублёвого баланса → конвертируем в переходы - added_clicks = int(rub // settings.CLICK_PRICE_RUB) - await self._balance_storage.add_balance(user_id, added_clicks) + # Пополнение — начисляем только рубли, переходы пользователь купит сам await self._rub_storage.add_balance(user_id, rub) + new_rub = await self._rub_storage.get_balance(user_id) msg = ( - f"✅ Пополнение получено!\n\n" + f"✅ Баланс пополнен!\n\n" f"💳 Сумма: {rub} ₽\n" - f"🖱 Начислено: {added_clicks} переходов " - f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n" - f"📊 Проверьте баланс в личном кабинете" + f"🪙 Рублей на балансе: {new_rub} ₽\n\n" + f"Перейдите в кабинет чтобы купить переходы." ) + history_record = { + "type": "topup", + "rub": rub, + "uuid": payment_uuid, + "at": datetime.now().isoformat(timespec="seconds"), + } + logger.info(f"Webhook: user={user_id} +{rub} RUB") await self._payment_storage.delete(payment_uuid) - logger.info( - f"Webhook: payment {payment_uuid} confirmed — " - f"user={user_id} +{added_clicks} clicks" - ) + await self._history_storage.add(user_id, history_record) - # Уведомляем пользователя if chat_id: - try: - await self._bot.send_message(chat_id, msg) - except Exception as e: - logger.warning(f"Webhook: failed to notify user {user_id}: {e}") + from aiogram.types import InlineKeyboardButton + from aiogram.utils.keyboard import InlineKeyboardBuilder + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🏠 Личный кабинет", callback_data="my_balance")) + kb = builder.as_markup() + + msg_id = payment.get("msg_id") + edited = False + if msg_id: + try: + await self._bot.edit_message_text( + msg, chat_id=chat_id, message_id=msg_id, + reply_markup=kb + ) + edited = True + except Exception: + pass + + if not edited: + try: + await self._bot.send_message(chat_id, msg, reply_markup=kb) + except Exception as e: + logger.warning(f"Webhook: failed to notify user {user_id}: {e}") except Exception as e: - logger.error(f"Webhook: error processing payment {payment_uuid}: {e}", exc_info=True) + logger.error(f"Webhook: error processing {payment_uuid}: {e}", exc_info=True) return web.Response(status=500, text="Internal error") return web.Response(text="ok")