diff --git a/.cursor/features/telecart-pulse-heartbeat.md b/.cursor/features/telecart-pulse-heartbeat.md new file mode 100644 index 0000000..0520067 --- /dev/null +++ b/.cursor/features/telecart-pulse-heartbeat.md @@ -0,0 +1,38 @@ +## TeleCart Pulse Heartbeat Telemetry + +### Цель +Раз в час отправлять телеметрию (heartbeat) на TeleCart Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя. + +### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`) +- `framework/TeleCartPulse/TeleCartPulseService.php` + - Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp. + - Последний успешный пинг кешируется (ключ `telecart_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`. + - Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи. + - Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-TELECART-VERSION`, взятым из `composer.json`. +- `framework/TeleCartPulse/TeleCartPulseServiceProvider.php` + - Регистрирует основной `PayloadSigner` (по `pulse.api_key`) и отдельный heartbeat signer (по `pulse.heartbeat_secret` или `PULSE_HEARTBEAT_SECRET`), инжектит `LoggerInterface`. +- `src/Handlers/TelemetryHandler.php` + `src/routes.php` + - Добавлен маршрут `heartbeat`, который вызывает `handleHeartbeat()` и возвращает `{ status: "ok" }`. Логгер пишет warning при проблемах. + +### Frontend (`frontend/spa`) +- `src/utils/ftch.js`: новая функция `heartbeat()` вызывает `api_action=heartbeat`. +- `src/stores/Pulse.js`: добавлен action `heartbeat`, использующий новую API-функцию и логирующий результат. +- `src/main.js`: после `pulse.ingest(...)` вызывается `pulse.heartbeat()` без блокировки цепочки. + +### Конфигурация / ENV +- `PULSE_API_HOST` — базовый URL TeleCart Pulse (используется и для events, и для heartbeat). +- `PULSE_TIMEOUT` — общий таймаут HTTP (для heartbeat принудительно 2 секунды). +- `PULSE_HEARTBEAT_SECRET` (или `pulse.heartbeat_secret` в настройках) — общий секрет для подписания heartbeat. Обязателен, иначе heartbeat не будет отправляться. +- `pulse.api_key` — прежний API ключ, используется только для event-инджеста. + +### Поведение +1. Frontend (SPA) вызывает `heartbeat` при инициализации приложения (fire-and-forget). +2. Backend проверяет кеш. Если часа еще не прошло, `handleHeartbeat()` возвращает без запросов. +3. При необходимости собираются данные, подписываются через heartbeat signer и отправляются POST-запросом на `/heartbeat`. +4. Любые сбои (bot info, подпись, HTTP) логируются как warning, чтобы не тревожить пользователей. + +### TODO / Возможные улучшения +- При необходимости вынести heartbeat запуск в крон/CLI, чтобы не зависеть от фронтенда. +- Добавить метрики успешности heartbeat в админку. + + diff --git a/frontend/spa/src/main.js b/frontend/spa/src/main.js index 8eb7bfc..02683e2 100644 --- a/frontend/spa/src/main.js +++ b/frontend/spa/src/main.js @@ -62,6 +62,9 @@ settings.load() .then(() => pulse.initFromStartParams()) .then(() => pulse.catchTelegramCustomerFromInitData()) .then(() => pulse.ingest(TC_PULSE_EVENTS.WEBAPP_OPEN)) + .then(() => { + pulse.heartbeat(); + }) .then(() => { (async () => { try { diff --git a/frontend/spa/src/stores/Pulse.js b/frontend/spa/src/stores/Pulse.js index 235be72..d599bcd 100644 --- a/frontend/spa/src/stores/Pulse.js +++ b/frontend/spa/src/stores/Pulse.js @@ -1,5 +1,5 @@ import {defineStore} from "pinia"; -import {ingest, saveTelegramCustomer} from "@/utils/ftch.js"; +import {ingest, saveTelegramCustomer, heartbeat} from "@/utils/ftch.js"; import {toRaw} from "vue"; import {deserializeStartParams} from "@/helpers.js"; @@ -46,5 +46,11 @@ export const usePulseStore = defineStore('pulse', { .catch(() => console.warn('[Pulse] Failed to save Telegram customer data:', error)); } }, + + heartbeat() { + heartbeat() + .then(() => console.debug('[Pulse] Heartbeat sent')) + .catch(err => console.warn('[Pulse] Heartbeat failed:', err)); + } }, }); diff --git a/frontend/spa/src/utils/ftch.js b/frontend/spa/src/utils/ftch.js index 5376ad8..3292755 100644 --- a/frontend/spa/src/utils/ftch.js +++ b/frontend/spa/src/utils/ftch.js @@ -127,4 +127,8 @@ export async function ingest(data) { return await ftchPost('ingest', data); } +export async function heartbeat() { + return await ftch('heartbeat'); +} + export default ftch; diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/.env b/module/oc_telegram_shop/upload/oc_telegram_shop/.env index 5f30767..4717692 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/.env +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/.env @@ -1,2 +1,3 @@ APP_DEBUG=true PULSE_API_HOST=http://host.docker.internal:8086/api/ +PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/.env.example b/module/oc_telegram_shop/upload/oc_telegram_shop/.env.example index 75f2dd7..fd5f298 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/.env.example +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/.env.example @@ -1,2 +1,3 @@ APP_DEBUG=false PULSE_API_HOST=http://host.docker.internal:8086/api/ +PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/.env.production b/module/oc_telegram_shop/upload/oc_telegram_shop/.env.production index 0e67d39..a300883 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/.env.production +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/.env.production @@ -1,2 +1,3 @@ APP_DEBUG=false PULSE_API_HOST=https://pulse.telecart.pro/api/ +PULSE_HEARTBEAT_SECRET=c5261f5d-529e-45ad-a69c-9778b755b7cb diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseService.php index 85acc57..dd56cb1 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseService.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseService.php @@ -6,25 +6,41 @@ use Carbon\Carbon; use GuzzleHttp\Client; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; +use Openguru\OpenCartFramework\Cache\CacheInterface; use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Utils; use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder; +use Openguru\OpenCartFramework\Telegram\TelegramService; +use Psr\Log\LoggerInterface; use Throwable; class TeleCartPulseService { private TelegramInitDataDecoder $initDataDecoder; private PayloadSigner $payloadSigner; + private TelegramService $telegramService; + private CacheInterface $cache; + private LoggerInterface $logger; private ?string $apiKey; + private ?PayloadSigner $heartbeatPayloadSigner; + private ?string $moduleVersion = null; public function __construct( TelegramInitDataDecoder $initDataDecoder, PayloadSigner $payloadSigner, - ?string $apiKey = null + TelegramService $telegramService, + CacheInterface $cache, + LoggerInterface $logger, + ?string $apiKey = null, + ?PayloadSigner $heartbeatPayloadSigner = null ) { $this->initDataDecoder = $initDataDecoder; $this->payloadSigner = $payloadSigner; + $this->telegramService = $telegramService; + $this->cache = $cache; + $this->logger = $logger; $this->apiKey = $apiKey; + $this->heartbeatPayloadSigner = $heartbeatPayloadSigner; } /** @@ -68,6 +84,65 @@ class TeleCartPulseService } } + /** + * @throws GuzzleException + */ + public function handleHeartbeat(): void + { + if ($this->cache->get('telecart_pulse_heartbeat')) { + return; + } + + try { + $this->cache->set('telecart_pulse_heartbeat', time(), 3600); + $me = $this->telegramService->getMe(); + } catch (Throwable $e) { + $this->logger->warning( + 'TeleCart Pulse heartbeat prerequisites failed: ' . $e->getMessage(), + ['exception' => $e] + ); + return; + } + + $botName = $me['username'] ?? 'unknown'; + + $moduleVersion = $this->getModuleVersion(); + + $payload = [ + 'event' => 'HEARTBEAT', + 'meta' => [ + 'domain' => Utils::getCurrentDomain(), + 'bot_name' => $botName, + 'php_version' => PHP_VERSION, + 'module_version' => $moduleVersion, + 'opencart_version' => defined('VERSION') ? VERSION : 'unknown', + 'opencart_version_core' => defined('VERSION_CORE') ? VERSION_CORE : 'unknown', + ], + 'timestamp' => Carbon::now('UTC')->toJSON(), + ]; + + if (! $this->heartbeatPayloadSigner) { + return; + } + + try { + $signature = $this->heartbeatPayloadSigner->sign($payload); + } catch (PayloadSignException $exception) { + $this->logger->warning( + 'TeleCart Pulse heartbeat signing failed: ' . $exception->getMessage(), + ['exception' => $exception] + ); + return; + } + + $dataToSend = [ + 'payload' => $payload, + 'signature' => $signature, + ]; + + $this->pushHeartbeat($dataToSend); + } + /** * @throws PayloadSignException * @throws GuzzleException @@ -109,13 +184,53 @@ class TeleCartPulseService 'timeout' => env('PULSE_TIMEOUT', 5.0), 'headers' => [ 'Authorization' => 'Bearer ' . $this->apiKey, - 'X-TELECART-VERSION' => '2.0.0', + 'X-TELECART-VERSION' => $this->getModuleVersion(), ], ]); $client->post('events', compact('json')); } + /** + * @throws GuzzleException + */ + private function pushHeartbeat(array $json): void + { + $baseUri = rtrim(env('PULSE_API_HOST', 'http://localhost'), '/') . '/'; + + $client = new Client([ + 'base_uri' => $baseUri, + 'timeout' => env('PULSE_TIMEOUT', 2.0), + 'headers' => [ + 'X-TELECART-VERSION' => $this->getModuleVersion(), + ], + ]); + + $client->post('heartbeat', compact('json')); + } + + private function getModuleVersion(): string + { + if ($this->moduleVersion !== null) { + return $this->moduleVersion; + } + + $moduleVersion = 'unknown'; + $composerPath = __DIR__ . '/../../composer.json'; + + if (file_exists($composerPath)) { + $composerRaw = @file_get_contents($composerPath); + if ($composerRaw !== false) { + $composer = json_decode($composerRaw, true); + if (is_array($composer) && isset($composer['version'])) { + $moduleVersion = (string)$composer['version']; + } + } + } + + return $this->moduleVersion = $moduleVersion; + } + private function handleOrderCreated(array $data, array $deserialized): void { if (isset($deserialized['campaign_id'], $deserialized['tracking_id'])) { diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseServiceProvider.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseServiceProvider.php index ee91f5a..8e741ce 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseServiceProvider.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/TeleCartPulse/TeleCartPulseServiceProvider.php @@ -2,9 +2,12 @@ namespace Openguru\OpenCartFramework\TeleCartPulse; +use Openguru\OpenCartFramework\Cache\CacheInterface; use Openguru\OpenCartFramework\Container\Container; use Openguru\OpenCartFramework\Container\ServiceProvider; use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder; +use Openguru\OpenCartFramework\Telegram\TelegramService; +use Psr\Log\LoggerInterface; class TeleCartPulseServiceProvider extends ServiceProvider { @@ -17,10 +20,17 @@ class TeleCartPulseServiceProvider extends ServiceProvider }); $this->container->singleton(TeleCartPulseService::class, function (Container $app) { + $heartbeatSecret = $app->getConfigValue('pulse.heartbeat_secret') ?? env('PULSE_HEARTBEAT_SECRET'); + $heartbeatSigner = $heartbeatSecret ? new PayloadSigner($heartbeatSecret) : null; + return new TeleCartPulseService( $app->get(TelegramInitDataDecoder::class), $app->get(PayloadSigner::class), + $app->get(TelegramService::class), + $app->get(CacheInterface::class), + $app->get(LoggerInterface::class), $app->getConfigValue('pulse.api_key'), + $heartbeatSigner, ); }); } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelemetryHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelemetryHandler.php index 9da8f5a..2b73a35 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelemetryHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelemetryHandler.php @@ -9,14 +9,20 @@ use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\Http\Response; use Openguru\OpenCartFramework\TeleCartPulse\PulseIngestException; use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseService; +use Psr\Log\LoggerInterface; +use Throwable; class TelemetryHandler { private TeleCartPulseService $teleCartPulseService; + private LoggerInterface $logger; - public function __construct(TeleCartPulseService $teleCartPulseService) - { + public function __construct( + TeleCartPulseService $teleCartPulseService, + LoggerInterface $logger + ) { $this->teleCartPulseService = $teleCartPulseService; + $this->logger = $logger; } /** @@ -28,4 +34,15 @@ class TelemetryHandler return new JsonResponse([], Response::HTTP_NO_CONTENT); } + + public function heartbeat(): JsonResponse + { + try { + $this->teleCartPulseService->handleHeartbeat(); + } catch (Throwable $e) { + $this->logger->warning('TeleCart Pulse Heartbeat failed: ' . $e->getMessage(), ['exception' => $e]); + } + + return new JsonResponse(['status' => 'ok']); + } } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php index 776a0f3..9bfc0b2 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/routes.php @@ -24,6 +24,7 @@ return [ 'getForm' => [FormsHandler::class, 'getForm'], 'health' => [HealthCheckHandler::class, 'handle'], 'ingest' => [TelemetryHandler::class, 'ingest'], + 'heartbeat' => [TelemetryHandler::class, 'heartbeat'], 'manifest' => [SettingsHandler::class, 'manifest'], 'processBlock' => [BlocksHandler::class, 'processBlock'], 'product_show' => [ProductsHandler::class, 'show'],