fix payment
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(pip install *)",
|
"Bash(pip install *)",
|
||||||
"Bash(python -c \"import psutil; print\\('psutil ok, version:', psutil.__version__\\)\")",
|
"Bash(python -c \"import psutil; print\\('psutil ok, version:', psutil.__version__\\)\")",
|
||||||
"Bash(python -c ' *)"
|
"Bash(python -c ' *)",
|
||||||
|
"WebFetch(domain:doc.heleket.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class Settings(BaseSettings):
|
|||||||
TOPUP_MAX_RUB: int = 50000 # Максимальное пополнение ₽
|
TOPUP_MAX_RUB: int = 50000 # Максимальное пополнение ₽
|
||||||
CLICKS_MIN: int = 10 # Минимальная покупка переходов
|
CLICKS_MIN: int = 10 # Минимальная покупка переходов
|
||||||
|
|
||||||
|
# Heleket webhook
|
||||||
|
HELEKET_WEBHOOK_URL: str = "" # Публичный URL: https://example.com/heleket/webhook
|
||||||
|
HELEKET_WEBHOOK_PORT: int = 8080 # Локальный порт для приёма вебхуков
|
||||||
|
HELEKET_WHITELIST_IP: bool = True # Проверять IP 31.133.220.8
|
||||||
|
|
||||||
# Telethon/MTProto settings
|
# Telethon/MTProto settings
|
||||||
API_ID: int = 12345
|
API_ID: int = 12345
|
||||||
API_HASH: str = "abc123"
|
API_HASH: str = "abc123"
|
||||||
@@ -32,6 +37,7 @@ class Settings(BaseSettings):
|
|||||||
# Browser Configuration
|
# Browser Configuration
|
||||||
VIEWPORT_WIDTH: int = 1366
|
VIEWPORT_WIDTH: int = 1366
|
||||||
VIEWPORT_HEIGHT: int = 768
|
VIEWPORT_HEIGHT: int = 768
|
||||||
|
BROWSER_POOL_SIZE: int = 3 # Число параллельных браузерных воркеров
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_MIN_DELAY: int = 30
|
DEFAULT_MIN_DELAY: int = 30
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from managers.background_tasks import BackgroundTaskManager
|
|||||||
from managers.proxy_manager import ProxyManager
|
from managers.proxy_manager import ProxyManager
|
||||||
from managers.task_manager import TaskManager, TaskParams
|
from managers.task_manager import TaskManager, TaskParams
|
||||||
from services.browser_service import BrowserService
|
from services.browser_service import BrowserService
|
||||||
|
from services.browser_pool import BrowserPool
|
||||||
from services.visit_service import VisitScheduler
|
from services.visit_service import VisitScheduler
|
||||||
from utils.helpers import parse_range, format_range, extract_domain
|
from utils.helpers import parse_range, format_range, extract_domain
|
||||||
from utils.telegram import send_message_safe, send_visit_result
|
from utils.telegram import send_message_safe, send_visit_result
|
||||||
@@ -46,6 +47,7 @@ class BotInterface:
|
|||||||
background_tasks: BackgroundTaskManager,
|
background_tasks: BackgroundTaskManager,
|
||||||
proxy_manager: ProxyManager,
|
proxy_manager: ProxyManager,
|
||||||
browser_service: BrowserService,
|
browser_service: BrowserService,
|
||||||
|
browser_pool: BrowserPool = None,
|
||||||
storage: TaskStorage = None,
|
storage: TaskStorage = None,
|
||||||
chat_storage: ChatStorage = None,
|
chat_storage: ChatStorage = None,
|
||||||
bot_ref=None,
|
bot_ref=None,
|
||||||
@@ -53,6 +55,7 @@ class BotInterface:
|
|||||||
self.background_tasks = background_tasks
|
self.background_tasks = background_tasks
|
||||||
self.proxy_manager = proxy_manager
|
self.proxy_manager = proxy_manager
|
||||||
self.browser_service = browser_service
|
self.browser_service = browser_service
|
||||||
|
self.browser_pool = browser_pool
|
||||||
self.task_manager = TaskManager()
|
self.task_manager = TaskManager()
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.chat_storage = chat_storage
|
self.chat_storage = chat_storage
|
||||||
@@ -595,6 +598,7 @@ class BotInterface:
|
|||||||
return
|
return
|
||||||
payment_data = {
|
payment_data = {
|
||||||
"user_id": uid,
|
"user_id": uid,
|
||||||
|
"chat_id": callback.message.chat.id,
|
||||||
"payment_id": invoice["payment_id"],
|
"payment_id": invoice["payment_id"],
|
||||||
"rub": amount,
|
"rub": amount,
|
||||||
"address": invoice.get("address"),
|
"address": invoice.get("address"),
|
||||||
@@ -911,6 +915,7 @@ class BotInterface:
|
|||||||
|
|
||||||
payment_data = {
|
payment_data = {
|
||||||
"user_id": uid,
|
"user_id": uid,
|
||||||
|
"chat_id": callback.message.chat.id,
|
||||||
"payment_id": invoice["payment_id"],
|
"payment_id": invoice["payment_id"],
|
||||||
"clicks": clicks,
|
"clicks": clicks,
|
||||||
"rub": rub,
|
"rub": rub,
|
||||||
@@ -2197,7 +2202,8 @@ class BotInterface:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.browser_service.visit_page(params.url, params.get_reading_time())
|
visitor = self.browser_pool or self.browser_service
|
||||||
|
result = await visitor.visit_page(params.url, params.get_reading_time())
|
||||||
params.total_visits += 1
|
params.total_visits += 1
|
||||||
if result.success:
|
if result.success:
|
||||||
params.successful_visits += 1
|
params.successful_visits += 1
|
||||||
@@ -2307,7 +2313,8 @@ class BotInterface:
|
|||||||
|
|
||||||
reading = params.get_reading_time()
|
reading = params.get_reading_time()
|
||||||
try:
|
try:
|
||||||
result = await self.browser_service.visit_page(url, reading)
|
visitor = self.browser_pool or self.browser_service
|
||||||
|
result = await visitor.visit_page(url, reading)
|
||||||
params.total_visits += 1
|
params.total_visits += 1
|
||||||
params.pending_visits = max(0, params.pending_visits - 1)
|
params.pending_visits = max(0, params.pending_visits - 1)
|
||||||
if result.success:
|
if result.success:
|
||||||
|
|||||||
+42
-50
@@ -1,10 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Нагрузочный тест: N параллельных браузеров на X секунд.
|
Нагрузочный тест: N визитов через BrowserPool.
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
python load_test.py --n 5
|
python load_test.py --n 5
|
||||||
python load_test.py --n 10 --url https://example.com --time 15
|
python load_test.py --n 10 --url https://example.com --time 15 --workers 3
|
||||||
python load_test.py --n 10 --parallel 3 # не более 3 одновременных запусков
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -22,6 +21,7 @@ import psutil
|
|||||||
from core.logger import setup_logger
|
from core.logger import setup_logger
|
||||||
from managers.proxy_manager import ProxyManager
|
from managers.proxy_manager import ProxyManager
|
||||||
from services.browser_service import BrowserService
|
from services.browser_service import BrowserService
|
||||||
|
from services.browser_pool import BrowserPool
|
||||||
|
|
||||||
logger = setup_logger(name="load_test", log_level="INFO", log_file="logs/load_test.log")
|
logger = setup_logger(name="load_test", log_level="INFO", log_file="logs/load_test.log")
|
||||||
|
|
||||||
@@ -38,46 +38,39 @@ class BrowserResult:
|
|||||||
success: bool
|
success: bool
|
||||||
duration: float
|
duration: float
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
final_url: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def run_one_browser(
|
async def submit_visit(
|
||||||
index: int,
|
index: int,
|
||||||
browser_service: BrowserService,
|
pool: BrowserPool,
|
||||||
url: str,
|
url: str,
|
||||||
visit_time: int,
|
visit_time: int,
|
||||||
results: List[BrowserResult],
|
results: List[BrowserResult],
|
||||||
launch_sem: asyncio.Semaphore,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Семафор ограничивает сколько браузеров стартует одновременно.
|
t0 = time.perf_counter()
|
||||||
# Когда браузер поднялся (AsyncCamoufox.__aenter__ завершён), семафор освобождается
|
try:
|
||||||
# и следующий браузер может начать запуск. Так избегаем race на ресурсы ОС.
|
result = await pool.visit_page(url, reading_time=visit_time)
|
||||||
async with launch_sem:
|
duration = time.perf_counter() - t0
|
||||||
t0 = time.perf_counter()
|
results.append(BrowserResult(
|
||||||
try:
|
index=index,
|
||||||
result = await browser_service.visit_page(url, reading_time=visit_time)
|
success=result.success,
|
||||||
duration = time.perf_counter() - t0
|
duration=duration,
|
||||||
results.append(BrowserResult(
|
error=result.error,
|
||||||
index=index,
|
))
|
||||||
success=result.success,
|
status = "OK" if result.success else f"FAIL: {result.error}"
|
||||||
duration=duration,
|
print(f" [#{index:>2}] {status} — {duration:.1f}s | RAM: {_mem_mb():.0f} MB")
|
||||||
error=result.error,
|
except Exception as e:
|
||||||
final_url=result.final_url,
|
duration = time.perf_counter() - t0
|
||||||
))
|
results.append(BrowserResult(index=index, success=False, duration=duration, error=str(e)))
|
||||||
status = "OK" if result.success else f"FAIL: {result.error}"
|
print(f" [#{index:>2}] EXCEPTION: {e} — {duration:.1f}s")
|
||||||
print(f" [Browser {index:>2}] {status} — {duration:.1f}s | RAM: {_mem_mb():.0f} MB")
|
|
||||||
except Exception as e:
|
|
||||||
duration = time.perf_counter() - t0
|
|
||||||
results.append(BrowserResult(index=index, success=False, duration=duration, error=str(e)))
|
|
||||||
print(f" [Browser {index:>2}] EXCEPTION: {e} — {duration:.1f}s")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_load_test(n: int, url: str, visit_time: int, parallel: int) -> None:
|
async def run_load_test(n: int, url: str, visit_time: int, workers: int) -> None:
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f" Нагрузочный тест")
|
print(f" Нагрузочный тест")
|
||||||
print(f" Браузеров: {n} | Одновременный запуск: {parallel} | URL: {url} | Время: {visit_time}s")
|
print(f" Визитов: {n} | Воркеров: {workers} | Время: {visit_time}s")
|
||||||
|
print(f" URL: {url}")
|
||||||
|
|
||||||
# Инфо о системе
|
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
print(f" RAM всего: {mem.total / (1024**3):.1f} GB | Свободно: {mem.available / (1024**3):.1f} GB")
|
print(f" RAM всего: {mem.total / (1024**3):.1f} GB | Свободно: {mem.available / (1024**3):.1f} GB")
|
||||||
print(f" CPU ядер: {psutil.cpu_count()}")
|
print(f" CPU ядер: {psutil.cpu_count()}")
|
||||||
@@ -87,24 +80,23 @@ async def run_load_test(n: int, url: str, visit_time: int, parallel: int) -> Non
|
|||||||
await proxy_manager.detect_types()
|
await proxy_manager.detect_types()
|
||||||
|
|
||||||
proxy_count = proxy_manager.count
|
proxy_count = proxy_manager.count
|
||||||
if proxy_count < n:
|
if proxy_count < workers:
|
||||||
print(f" ВНИМАНИЕ: прокси {proxy_count} < браузеров {n}.")
|
print(f" ВНИМАНИЕ: прокси {proxy_count} < воркеров {workers}.")
|
||||||
print(f" Некоторые браузеры могут не получить прокси.\n")
|
print(f" Часть воркеров может ждать свободный прокси.\n")
|
||||||
|
|
||||||
browser_service = BrowserService(proxy_manager)
|
browser_service = BrowserService(proxy_manager)
|
||||||
results: List[BrowserResult] = []
|
pool = BrowserPool(size=workers, browser_service=browser_service)
|
||||||
|
await pool.start()
|
||||||
|
|
||||||
|
results: List[BrowserResult] = []
|
||||||
ram_before = _mem_mb()
|
ram_before = _mem_mb()
|
||||||
print(f" RAM до старта: {ram_before:.0f} MB")
|
print(f" RAM до старта: {ram_before:.0f} MB")
|
||||||
print(f"\n Запускаем {n} браузеров (волнами по {parallel})...\n")
|
print(f"\n Отправляем {n} визитов в пул ({workers} воркеров)...\n")
|
||||||
|
|
||||||
launch_sem = asyncio.Semaphore(parallel)
|
|
||||||
wall_start = time.perf_counter()
|
wall_start = time.perf_counter()
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(
|
asyncio.create_task(submit_visit(i + 1, pool, url, visit_time, results))
|
||||||
run_one_browser(i + 1, browser_service, url, visit_time, results, launch_sem)
|
|
||||||
)
|
|
||||||
for i in range(n)
|
for i in range(n)
|
||||||
]
|
]
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -112,9 +104,9 @@ async def run_load_test(n: int, url: str, visit_time: int, parallel: int) -> Non
|
|||||||
wall_elapsed = time.perf_counter() - wall_start
|
wall_elapsed = time.perf_counter() - wall_start
|
||||||
ram_after = _mem_mb()
|
ram_after = _mem_mb()
|
||||||
|
|
||||||
|
await pool.stop()
|
||||||
await browser_service.cleanup()
|
await browser_service.cleanup()
|
||||||
|
|
||||||
# --- Итоги ---
|
|
||||||
ok = sum(1 for r in results if r.success)
|
ok = sum(1 for r in results if r.success)
|
||||||
fail = len(results) - ok
|
fail = len(results) - ok
|
||||||
avg_dur = sum(r.duration for r in results) / len(results) if results else 0
|
avg_dur = sum(r.duration for r in results) / len(results) if results else 0
|
||||||
@@ -124,34 +116,34 @@ async def run_load_test(n: int, url: str, visit_time: int, parallel: int) -> Non
|
|||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f" ИТОГИ")
|
print(f" ИТОГИ")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
print(f" Всего браузеров : {n}")
|
print(f" Визитов всего : {n}")
|
||||||
print(f" Успешно : {ok}")
|
print(f" Успешно : {ok}")
|
||||||
print(f" Ошибки : {fail}")
|
print(f" Ошибки : {fail}")
|
||||||
print(f" Общее время : {wall_elapsed:.1f}s")
|
print(f" Общее время : {wall_elapsed:.1f}s")
|
||||||
print(f" Время посещения : avg={avg_dur:.1f}s min={min_dur:.1f}s max={max_dur:.1f}s")
|
print(f" Время визита : avg={avg_dur:.1f}s min={min_dur:.1f}s max={max_dur:.1f}s")
|
||||||
print(f" RAM до/после : {ram_before:.0f} MB → {ram_after:.0f} MB (Δ {ram_after - ram_before:+.0f} MB)")
|
print(f" RAM до/после : {ram_before:.0f} MB → {ram_after:.0f} MB (Δ {ram_after - ram_before:+.0f} MB)")
|
||||||
print(f" RAM на браузер : ~{(ram_after - ram_before) / n if n else 0:.0f} MB")
|
print(f" RAM на воркер : ~{(ram_after - ram_before) / workers if workers else 0:.0f} MB")
|
||||||
|
|
||||||
if fail:
|
if fail:
|
||||||
print(f"\n Ошибки:")
|
print(f"\n Ошибки:")
|
||||||
for r in results:
|
for r in results:
|
||||||
if not r.success:
|
if not r.success:
|
||||||
print(f" Browser {r.index}: {r.error}")
|
print(f" #{r.index}: {r.error}")
|
||||||
|
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Load test: N параллельных браузеров")
|
parser = argparse.ArgumentParser(description="Load test через BrowserPool")
|
||||||
parser.add_argument("--n", type=int, default=3, help="Всего браузеров (по умолчанию: 3)")
|
parser.add_argument("--n", type=int, default=5, help="Число визитов (по умолчанию: 5)")
|
||||||
parser.add_argument("--parallel", type=int, default=2, help="Макс. одновременных запусков (по умолчанию: 2)")
|
parser.add_argument("--workers", type=int, default=2, help="Число параллельных воркеров/браузеров (по умолчанию: 2)")
|
||||||
parser.add_argument("--url", type=str, default="https://httpbin.org/headers", help="URL для посещения")
|
parser.add_argument("--url", type=str, default="https://httpbin.org/headers", help="URL для посещения")
|
||||||
parser.add_argument("--time", type=int, default=10, dest="visit_time", help="Время посещения в секундах (по умолчанию: 10)")
|
parser.add_argument("--time", type=int, default=10, dest="visit_time", help="Время посещения в секундах (по умолчанию: 10)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
parallel = min(args.parallel, args.n)
|
workers = min(args.workers, args.n)
|
||||||
try:
|
try:
|
||||||
asyncio.run(run_load_test(args.n, args.url, args.visit_time, parallel))
|
asyncio.run(run_load_test(args.n, args.url, args.visit_time, workers))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nПрервано.")
|
print("\nПрервано.")
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from core.logger import setup_logger
|
|||||||
from managers.proxy_manager import ProxyManager
|
from managers.proxy_manager import ProxyManager
|
||||||
from managers.background_tasks import BackgroundTaskManager
|
from managers.background_tasks import BackgroundTaskManager
|
||||||
from services.browser_service import BrowserService
|
from services.browser_service import BrowserService
|
||||||
|
from services.browser_pool import BrowserPool
|
||||||
|
from services.webhook_server import HelketWebhookServer
|
||||||
from handlers.commands import BotInterface
|
from handlers.commands import BotInterface
|
||||||
from managers.storage import TaskStorage, ChatStorage
|
from managers.storage import TaskStorage, ChatStorage
|
||||||
|
|
||||||
@@ -45,13 +47,16 @@ class BotApplication:
|
|||||||
self.proxy_manager = ProxyManager()
|
self.proxy_manager = ProxyManager()
|
||||||
self.background_tasks = BackgroundTaskManager()
|
self.background_tasks = BackgroundTaskManager()
|
||||||
self.browser_service = BrowserService(self.proxy_manager)
|
self.browser_service = BrowserService(self.proxy_manager)
|
||||||
|
self.browser_pool = BrowserPool(settings.BROWSER_POOL_SIZE, self.browser_service)
|
||||||
self.storage = TaskStorage()
|
self.storage = TaskStorage()
|
||||||
self.chat_storage = ChatStorage()
|
self.chat_storage = ChatStorage()
|
||||||
|
self.webhook_server: HelketWebhookServer = None # создаём после инициализации bot
|
||||||
|
|
||||||
self.interface = BotInterface(
|
self.interface = BotInterface(
|
||||||
background_tasks=self.background_tasks,
|
background_tasks=self.background_tasks,
|
||||||
proxy_manager=self.proxy_manager,
|
proxy_manager=self.proxy_manager,
|
||||||
browser_service=self.browser_service,
|
browser_service=self.browser_service,
|
||||||
|
browser_pool=self.browser_pool,
|
||||||
storage=self.storage,
|
storage=self.storage,
|
||||||
chat_storage=self.chat_storage,
|
chat_storage=self.chat_storage,
|
||||||
bot_ref=self,
|
bot_ref=self,
|
||||||
@@ -82,6 +87,21 @@ class BotApplication:
|
|||||||
# Определяем типы прокси без явного протокола
|
# Определяем типы прокси без явного протокола
|
||||||
await self.proxy_manager.detect_types()
|
await self.proxy_manager.detect_types()
|
||||||
|
|
||||||
|
# Запускаем пул браузеров
|
||||||
|
await self.browser_pool.start()
|
||||||
|
logger.info(f"Browser pool: {settings.BROWSER_POOL_SIZE} workers")
|
||||||
|
|
||||||
|
# Запускаем webhook-сервер для Heleket (только если настроен)
|
||||||
|
if settings.HELEKET_API_KEY:
|
||||||
|
self.webhook_server = HelketWebhookServer(
|
||||||
|
payment_storage=self.interface.payment_storage,
|
||||||
|
balance_storage=self.interface.balance_storage,
|
||||||
|
rub_storage=self.interface.rub_storage,
|
||||||
|
bot=self.bot,
|
||||||
|
port=settings.HELEKET_WEBHOOK_PORT,
|
||||||
|
)
|
||||||
|
await self.webhook_server.start()
|
||||||
|
|
||||||
# Восстанавливаем задачи
|
# Восстанавливаем задачи
|
||||||
await self._restore_tasks()
|
await self._restore_tasks()
|
||||||
|
|
||||||
@@ -165,6 +185,9 @@ class BotApplication:
|
|||||||
await self.storage.save_tasks(tasks)
|
await self.storage.save_tasks(tasks)
|
||||||
|
|
||||||
await self.background_tasks.cancel_all()
|
await self.background_tasks.cancel_all()
|
||||||
|
await self.browser_pool.stop()
|
||||||
|
if self.webhook_server:
|
||||||
|
await self.webhook_server.stop()
|
||||||
await self.browser_service.cleanup()
|
await self.browser_service.cleanup()
|
||||||
if self.bot:
|
if self.bot:
|
||||||
await self.bot.session.close()
|
await self.bot.session.close()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
BrowserPool — N воркеров с очередью визитов.
|
||||||
|
|
||||||
|
Каждый воркер:
|
||||||
|
1. Берёт визит из очереди
|
||||||
|
2. Поднимает свежий браузер (через BrowserService)
|
||||||
|
3. Делает ровно 1 визит
|
||||||
|
4. Закрывает браузер
|
||||||
|
5. Идёт за следующим заданием
|
||||||
|
|
||||||
|
Пока воркер читает страницу, другие воркеры уже могут запускать
|
||||||
|
браузеры для следующих визитов — стартап перекрывается с чтением.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from services.browser_service import BrowserService, VisitResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _VisitRequest:
|
||||||
|
url: str
|
||||||
|
reading_time: Optional[int]
|
||||||
|
future: asyncio.Future
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserPool:
|
||||||
|
"""
|
||||||
|
Пул из N воркеров. Drop-in замена для BrowserService.visit_page().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, size: int, browser_service: BrowserService):
|
||||||
|
self._size = size
|
||||||
|
self._bs = browser_service
|
||||||
|
self._queue: asyncio.Queue[Optional[_VisitRequest]] = asyncio.Queue()
|
||||||
|
self._workers: list[asyncio.Task] = []
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
for i in range(self._size):
|
||||||
|
t = asyncio.create_task(self._worker(i), name=f"browser-pool-{i}")
|
||||||
|
self._workers.append(t)
|
||||||
|
logger.info(f"BrowserPool started: {self._size} workers")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
self._running = False
|
||||||
|
for _ in self._workers:
|
||||||
|
await self._queue.put(None) # sentinel — один на каждого воркера
|
||||||
|
await asyncio.gather(*self._workers, return_exceptions=True)
|
||||||
|
self._workers.clear()
|
||||||
|
logger.info("BrowserPool stopped")
|
||||||
|
|
||||||
|
async def visit_page(self, url: str, reading_time: int = None) -> VisitResult:
|
||||||
|
"""Отправляет визит в очередь и ждёт результата."""
|
||||||
|
if not self._running:
|
||||||
|
# Пул не запущен — fallback на прямой вызов
|
||||||
|
return await self._bs.visit_page(url, reading_time)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
future: asyncio.Future[VisitResult] = loop.create_future()
|
||||||
|
await self._queue.put(_VisitRequest(url=url, reading_time=reading_time, future=future))
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _worker(self, idx: int) -> None:
|
||||||
|
logger.debug(f"Pool worker-{idx} ready")
|
||||||
|
while True:
|
||||||
|
item = await self._queue.get()
|
||||||
|
if item is None: # sentinel → выход
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
result = await self._bs.visit_page(item.url, item.reading_time)
|
||||||
|
if not item.future.done():
|
||||||
|
item.future.set_result(result)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not item.future.done():
|
||||||
|
item.future.cancel()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Pool worker-{idx} error: {e}", exc_info=True)
|
||||||
|
if not item.future.done():
|
||||||
|
item.future.set_result(
|
||||||
|
VisitResult(url=item.url, success=False, error=str(e))
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._queue.task_done()
|
||||||
|
logger.debug(f"Pool worker-{idx} stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return self._size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
return self._queue.qsize()
|
||||||
+38
-15
@@ -1,7 +1,11 @@
|
|||||||
"""Heleket payment integration."""
|
"""Heleket payment integration."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from config.settings import settings
|
from config.settings import settings
|
||||||
@@ -9,6 +13,12 @@ from config.settings import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sign(body_bytes: bytes, api_key: str) -> str:
|
||||||
|
"""Генерирует подпись запроса: md5(base64(body) + api_key)."""
|
||||||
|
b64 = base64.b64encode(body_bytes).decode("utf-8")
|
||||||
|
return hashlib.md5((b64 + api_key).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class HelketPayment:
|
class HelketPayment:
|
||||||
"""Heleket payment gateway. Принимает сумму в рублях напрямую."""
|
"""Heleket payment gateway. Принимает сумму в рублях напрямую."""
|
||||||
|
|
||||||
@@ -19,6 +29,21 @@ class HelketPayment:
|
|||||||
self.shop_id = settings.HELEKET_SHOP_ID
|
self.shop_id = settings.HELEKET_SHOP_ID
|
||||||
self._mock = not (self.api_key and self.shop_id)
|
self._mock = not (self.api_key and self.shop_id)
|
||||||
|
|
||||||
|
def _post_headers(self, body_bytes: bytes) -> dict:
|
||||||
|
"""Заголовки для POST-запроса с подписью."""
|
||||||
|
return {
|
||||||
|
"merchant": self.api_key,
|
||||||
|
"sign": _make_sign(body_bytes, self.api_key),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict:
|
||||||
|
"""Заголовки для GET-запроса (тело пустое)."""
|
||||||
|
return {
|
||||||
|
"merchant": self.api_key,
|
||||||
|
"sign": _make_sign(b"", self.api_key),
|
||||||
|
}
|
||||||
|
|
||||||
async def create_invoice(self, amount_rub: int, order_id: str) -> Optional[dict]:
|
async def create_invoice(self, amount_rub: int, order_id: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Создаёт счёт на сумму в рублях.
|
Создаёт счёт на сумму в рублях.
|
||||||
@@ -32,21 +57,20 @@ class HelketPayment:
|
|||||||
"mock": True,
|
"mock": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"amount": str(amount_rub),
|
||||||
|
"currency": "RUB",
|
||||||
|
"order_id": order_id,
|
||||||
|
"url_callback": settings.HELEKET_WEBHOOK_URL,
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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(
|
async with session.post(
|
||||||
f"{self.BASE_URL}/payment",
|
f"{self.BASE_URL}/payment",
|
||||||
json=payload,
|
data=body,
|
||||||
headers=headers,
|
headers=self._post_headers(body),
|
||||||
timeout=aiohttp.ClientTimeout(total=15),
|
timeout=aiohttp.ClientTimeout(total=15),
|
||||||
) as resp:
|
) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -70,18 +94,17 @@ class HelketPayment:
|
|||||||
return "pending"
|
return "pending"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
headers = {"merchant": self.api_key}
|
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{self.BASE_URL}/payment/info/{payment_id}",
|
f"{self.BASE_URL}/payment/info/{payment_id}",
|
||||||
headers=headers,
|
headers=self._get_headers(),
|
||||||
timeout=aiohttp.ClientTimeout(total=15),
|
timeout=aiohttp.ClientTimeout(total=15),
|
||||||
) as resp:
|
) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
if data.get("state") == 0:
|
if data.get("state") == 0:
|
||||||
status = data.get("result", {}).get("status", "")
|
status = data.get("result", {}).get("status", "")
|
||||||
if status == "paid":
|
if status in ("paid", "paid_over"):
|
||||||
return "paid"
|
return "paid"
|
||||||
if status in ("cancel", "expired"):
|
if status in ("cancel", "expired", "fail", "wrong_amount"):
|
||||||
return "expired"
|
return "expired"
|
||||||
return "pending"
|
return "pending"
|
||||||
return "error"
|
return "error"
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Webhook-сервер для приёма уведомлений от Heleket об оплате.
|
||||||
|
|
||||||
|
Heleket шлёт POST на url_callback когда статус счёта меняется.
|
||||||
|
Сервер верифицирует подпись, начисляет баланс и уведомляет пользователя в Telegram.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Доверенный IP Heleket из документации
|
||||||
|
HELEKET_IP = "31.133.220.8"
|
||||||
|
|
||||||
|
|
||||||
|
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, поэтому вручную экранируем слэши.
|
||||||
|
"""
|
||||||
|
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_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
|
||||||
|
port: int = 8080,
|
||||||
|
):
|
||||||
|
self._payment_storage = payment_storage
|
||||||
|
self._balance_storage = balance_storage
|
||||||
|
self._rub_storage = rub_storage
|
||||||
|
self._bot = bot
|
||||||
|
self._port = port
|
||||||
|
self._app = web.Application()
|
||||||
|
self._runner: Optional[web.AppRunner] = None
|
||||||
|
self._api_key = settings.HELEKET_API_KEY
|
||||||
|
|
||||||
|
self._app.router.add_post("/heleket/webhook", self._handle_webhook)
|
||||||
|
self._app.router.add_get("/health", self._handle_health)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._runner = web.AppRunner(self._app)
|
||||||
|
await self._runner.setup()
|
||||||
|
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
|
||||||
|
await site.start()
|
||||||
|
logger.info(f"Heleket webhook server started on port {self._port}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._runner:
|
||||||
|
await self._runner.cleanup()
|
||||||
|
logger.info("Heleket webhook server stopped")
|
||||||
|
|
||||||
|
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||||
|
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}")
|
||||||
|
return web.Response(status=403, text="Forbidden")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Webhook: invalid JSON body")
|
||||||
|
return web.Response(status=400, text="Bad Request")
|
||||||
|
|
||||||
|
logger.debug(f"Webhook payload: {payload}")
|
||||||
|
|
||||||
|
# Верифицируем подпись если есть 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")
|
||||||
|
|
||||||
|
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") # уже обработан или неизвестный
|
||||||
|
|
||||||
|
user_id = payment.get("user_id")
|
||||||
|
chat_id = payment.get("chat_id") or user_id
|
||||||
|
clicks = payment.get("clicks")
|
||||||
|
rub = payment.get("rub", 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if clicks:
|
||||||
|
# Прямая покупка переходов
|
||||||
|
await self._balance_storage.add(user_id, clicks)
|
||||||
|
added_clicks = clicks
|
||||||
|
msg = (
|
||||||
|
f"✅ Оплата получена!\n\n"
|
||||||
|
f"💳 Сумма: {rub} ₽\n"
|
||||||
|
f"🖱 Начислено: {clicks} переходов\n"
|
||||||
|
f"📊 Проверьте баланс в личном кабинете"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Пополнение рублёвого баланса → конвертируем в переходы
|
||||||
|
added_clicks = int(rub // settings.CLICK_PRICE_RUB)
|
||||||
|
await self._balance_storage.add(user_id, added_clicks)
|
||||||
|
await self._rub_storage.add(user_id, rub)
|
||||||
|
msg = (
|
||||||
|
f"✅ Пополнение получено!\n\n"
|
||||||
|
f"💳 Сумма: {rub} ₽\n"
|
||||||
|
f"🖱 Начислено: {added_clicks} переходов "
|
||||||
|
f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n"
|
||||||
|
f"📊 Проверьте баланс в личном кабинете"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._payment_storage.delete(payment_uuid)
|
||||||
|
logger.info(
|
||||||
|
f"Webhook: payment {payment_uuid} confirmed — "
|
||||||
|
f"user={user_id} +{added_clicks} clicks"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Уведомляем пользователя
|
||||||
|
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}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Webhook: error processing payment {payment_uuid}: {e}", exc_info=True)
|
||||||
|
return web.Response(status=500, text="Internal error")
|
||||||
|
|
||||||
|
return web.Response(text="ok")
|
||||||
Reference in New Issue
Block a user