hasPriceChannelAccess()) { return; } if (! $this->config->getBool('price_channel_enabled', false)) { return; } $dispatch = static function (): void { app(self::class)->broadcastIfEligible(); }; if (app()->runningInConsole()) { $dispatch(); return; } dispatch($dispatch)->afterResponse(); } /** * نقطه‌ی ورود — بعد از تغییر قیمت صدا زده می‌شود. * فقط وقتی قابلیت روشن است، پیام‌رسانی انتخاب شده و فاصله‌ی مجاز گذشته باشد ارسال می‌کند. */ public function broadcastIfEligible(): void { try { if (! $this->hasPriceChannelAccess()) { return; } if (! $this->config->getBool('price_channel_enabled', false)) { return; } $targets = $this->resolveActiveTargets(); if ($targets === []) { Log::debug('PriceChannel: enabled ولی هیچ مقصد فعالی نیست (توکن/مقصد را چک کن)'); return; } $lock = Cache::lock(self::SEND_LOCK_KEY, 30); if (! $lock->get()) { return; } try { $products = $this->eligibleProducts(); $boardProduct = $this->resolveBoardProduct($products); if ($boardProduct === null) { Log::debug('PriceChannel: محصول تابلو (آبشده) واجد شرایطی نیست'); return; } $trade = $this->resolveLiveTradePrices(); if ($trade === null || $trade['buy'] <= 0) { Log::debug('PriceChannel: مظنه زنده تابلو در دسترس نیست'); return; } // اول امضا — بدون مصرف cooldown (باگ قبلی: cooldown قبل از امضا باعث می‌شد // هر ۱۲۰ ثانیه یک «اسلات» بسوزد حتی وقتی قیمت عوض نشده یا ارسال ناموفق بود) $signature = $this->broadcastSignature($trade); if ($signature === Cache::get(self::SIGNATURE_CACHE_KEY)) { return; } if (! $this->cooldownElapsed()) { $this->scheduleDeferredBroadcast($this->cooldownSecondsRemaining() + 1); Log::debug('PriceChannel: قیمت عوض شده ولی cooldown هنوز تمام نشده — ارسال به‌تأخیر افتاد', [ 'remaining' => $this->cooldownSecondsRemaining(), ]); return; } $message = $this->buildMessage($products, $boardProduct); if ($message === '') { return; } if (! $this->acquireSendCooldown()) { return; } $results = []; $anyOk = false; foreach ($targets as $messenger => $target) { $results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); foreach ((array) $results[$messenger] as $status) { if (is_string($status) && str_starts_with($status, 'ok')) { $anyOk = true; } } } if (! $anyOk) { Cache::forget(self::COOLDOWN_CACHE_KEY); Log::warning('PriceChannel: هیچ ارسالی موفق نبود — cooldown آزاد شد تا تلاش مجدد', [ 'results' => $results, ]); return; } Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay()); Log::info('PriceChannel: قیمت به کانال ارسال شد', [ 'targets' => array_keys($targets), 'products' => count($products), 'buy_rial' => $trade['buy'], 'sell_rial' => $trade['sell'], 'results' => $results, ]); } finally { $lock->release(); } } catch (\Throwable $e) { Log::warning('PriceChannelBroadcast failed', ['message' => $e->getMessage()]); } } /** * امضای ارسال — buy/sell خام API (ریال) + قالب؛ دقیق‌تر از رشتهٔ فرمت‌شدهٔ نمایشی. * * @param array{buy: int, sell: int}|null $trade */ private function broadcastSignature(?array $trade = null): string { $trade ??= $this->resolveLiveTradePrices(); if ($trade === null) { return ''; } $channelDiff = (int) $this->config->getInt('price_channel_diff', 0); return md5(implode('|', [ (int) $trade['buy'] + $channelDiff, (int) $trade['sell'] - $channelDiff, $this->messageTemplate(), ])); } /** * پس از ذخیرهٔ قالب در ادمین — یک بار فوری ارسال (بدون cooldown و بدون چک امضا). */ public function broadcastAfterTemplateChange(): void { try { if (! $this->hasPriceChannelAccess()) { return; } if (! $this->config->getBool('price_channel_enabled', false)) { return; } $targets = $this->resolveActiveTargets(); if ($targets === []) { return; } $lock = Cache::lock(self::SEND_LOCK_KEY, 30); if (! $lock->get()) { return; } try { $products = $this->eligibleProducts(); $boardProduct = $this->resolveBoardProduct($products); if ($boardProduct === null) { return; } $trade = $this->resolveLiveTradePrices(); if ($trade === null || $trade['buy'] <= 0) { return; } if (! $this->cooldownElapsed()) { Log::debug('PriceChannel: قالب عوض شد ولی cooldown هنوز تمام نشده'); return; } $message = $this->buildMessage($products, $boardProduct); if ($message === '') { return; } if (! $this->acquireSendCooldown()) { return; } $results = []; $anyOk = false; foreach ($targets as $messenger => $target) { $results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); foreach ((array) $results[$messenger] as $status) { if (is_string($status) && str_starts_with($status, 'ok')) { $anyOk = true; } } } if (! $anyOk) { Cache::forget(self::COOLDOWN_CACHE_KEY); return; } $signature = $this->broadcastSignature($trade); Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay()); Log::info('PriceChannel: قالب پیام تغییر کرد — ارسال به کانال', [ 'targets' => array_keys($targets), 'results' => $results, ]); } finally { $lock->release(); } } catch (\Throwable $e) { Log::warning('PriceChannel template broadcast failed', ['message' => $e->getMessage()]); } } /** * ارسال دستی (عیب‌یابی) — cooldown و شرط تغییر قیمت را نادیده می‌گیرد. * وضعیت روشن/خاموش، انتخاب پیام‌رسان، توکن و مقصد همچنان لازم است. * * @return array */ public function forceSend(): array { $message = $this->buildMessage(); $targets = $this->resolveActiveTargets(); $results = []; foreach ($targets as $messenger => $target) { $results[$messenger] = $message === '' ? ['skipped' => 'پیام خالی است (محصول واجد شرایط نیست)'] : $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message); } if ($message !== '' && $targets !== []) { $this->acquireSendCooldown(true); Cache::put(self::SIGNATURE_CACHE_KEY, $this->broadcastSignature(), now()->addDay()); } return [ 'sent' => $message !== '' && $targets !== [], 'results' => $results, ]; } /** * گزارش وضعیت — چرا ارسال می‌شود یا نمی‌شود. * * @return array */ public function diagnose(): array { $selected = $this->selectedMessengers(); $messengers = []; foreach (self::SUPPORTED_MESSENGERS as $messenger) { $token = trim((string) $this->config->getString($this->tokenKey($messenger), '')); $chatIds = $this->parseList($this->config->getString($this->chatIdsKey($messenger), '')); $isSelected = in_array($messenger, $selected, true); $messengers[$messenger] = [ 'selected' => $isSelected, 'has_token' => $token !== '', 'destinations' => $chatIds, 'will_send' => $this->hasPriceChannelAccess() && $isSelected && $token !== '' && $chatIds !== [], ]; } $message = $this->buildMessage(); $fromConfig = trim((string) $this->config->getString('price_channel_message_text', '')); $trade = $this->resolveLiveTradePrices(); $display = $trade !== null ? $this->computeDisplayPrices() : null; return [ 'access' => $this->hasPriceChannelAccess(), 'enabled' => $this->config->getBool('price_channel_enabled', false), 'selected_messengers' => $selected, 'min_seconds' => $this->config->getInt('price_channel_min_seconds', 120), 'cooldown_elapsed' => $this->cooldownElapsed(), 'cooldown_seconds_remaining' => $this->cooldownSecondsRemaining(), 'signature_changed' => $trade !== null && $this->broadcastSignature($trade) !== Cache::get(self::SIGNATURE_CACHE_KEY), 'eligible_products' => count($this->eligibleProducts()), 'messengers' => $messengers, 'active_targets' => array_keys($this->resolveActiveTargets()), 'template_source' => $fromConfig !== '' ? 'db' : 'default', 'live_trade_buy_rial' => $trade['buy'] ?? 0, 'live_trade_sell_rial' => $trade['sell'] ?? 0, 'channel_gram_sell' => $display['gram_sell'] ?? '', 'channel_gram_buy' => $display['gram_buy'] ?? '', 'message_preview' => $message, ]; } /** * پیام‌رسان‌های قابل ارسال: انتخاب‌شده + توکن معتبر + حداقل یک مقصد. * * @return array}> */ private function resolveActiveTargets(): array { $selected = $this->selectedMessengers(); if ($selected === []) { return []; } $targets = []; foreach ($selected as $messenger) { $token = trim((string) $this->config->getString($this->tokenKey($messenger), '')); $chatIds = $this->parseList($this->config->getString($this->chatIdsKey($messenger), '')); if ($token === '' || $chatIds === []) { continue; } $targets[$messenger] = ['token' => $token, 'chat_ids' => $chatIds]; } return $targets; } /** @return array */ private function selectedMessengers(): array { $raw = $this->parseList($this->config->getString('price_channel_messengers', '')); return array_values(array_filter( array_map(static fn ($m) => strtolower(trim($m)), $raw), static fn ($m) => in_array($m, self::SUPPORTED_MESSENGERS, true) )); } private function tokenKey(string $messenger): string { return match ($messenger) { 'bale' => 'bale_bot_token', 'telegram' => 'telegram_bot_token', 'rubika' => 'rubika_bot_token', default => '', }; } private function chatIdsKey(string $messenger): string { return "price_channel_{$messenger}_to"; } /** * ساخت متن پیام قیمت — قالب از price_channel_message_text (یا پیش‌فرض داخلی). */ public function buildMessage(?array $products = null, ?Product $boardProduct = null): string { $products = $products ?? $this->eligibleProducts(); $boardProduct = $boardProduct ?? $this->resolveBoardProduct($products); if ($boardProduct === null) { return ''; } $trade = $this->resolveLiveTradePrices(); if ($trade === null || $trade['buy'] <= 0) { return ''; } return $this->renderMessageTemplate($this->messageTemplate(), $products, $boardProduct); } private function messageTemplate(): string { $fromConfig = trim((string) $this->config->getString('price_channel_message_text', '')); return $fromConfig !== '' ? $fromConfig : self::DEFAULT_MESSAGE_TEMPLATE; } /** * جایگزینی متغیرهای قالب. * قیمت تابلو: %گرم_خرید %گرم_فروش %مثقال_خرید %مثقال_فروش * زمان: %تاریخ %ساعت — اپ: %اسم_اپ %نام %عنوان * تماس: %آیدی %شماره %لینک — اختیاری: %محصولات %فوتر * * @param array $products */ private function renderMessageTemplate(string $template, array $products, Product $boardProduct): string { $name = trim((string) $this->config->getString('name_fa', '')); $title = $name !== '' ? "📊 قیمت لحظه‌ای {$name}" : '📊 قیمت لحظه‌ای'; $prices = $this->computeDisplayPrices(); $message = $this->applyTemplatePlaceholders($template, [ 'مثقال_فروش' => $prices['mesghal_sell'], 'مثقال_خرید' => $prices['mesghal_buy'], 'گرم_فروش' => $prices['gram_sell'], 'گرم_خرید' => $prices['gram_buy'], 'اسم_اپ' => $name, 'محصولات' => $this->buildProductLines($products), 'عنوان' => $title, 'تاریخ' => Verta::now()->format('Y/n/j'), 'ساعت' => now()->format('H:i'), 'آیدی' => trim((string) $this->config->getString('price_channel_footer_id', '')), 'شماره' => trim((string) $this->config->getString('price_channel_footer_phone', '')), 'لینک' => trim((string) $this->config->getString('price_channel_footer_link', '')), 'فوتر' => $this->buildFooterBlock(), 'نام' => $name, ]); return preg_replace("/\n{3,}/", "\n\n", trim($message)) ?? trim($message); } /** @param array $replacements */ private function applyTemplatePlaceholders(string $template, array $replacements): string { $keys = array_keys($replacements); usort($keys, static fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); $message = $template; foreach ($keys as $key) { $message = preg_replace( '/[%٪]\s*' . preg_quote($key, '/') . '/u', $replacements[$key], $message ) ?? $message; } return $message; } /** * محصول مرجع قیمت تابلو — اول آبشده (گرم)، بعد اولین محصول گرم، بعد اولین واجد شرایط. * * @param array $products */ private function resolveBoardProduct(array $products): ?Product { if ($products === []) { return null; } foreach ($products as $product) { if (trim((string) ($product->group_type ?? '')) === 'آبشده' && $this->isGramUnit($product)) { return $product; } } foreach ($products as $product) { if ($this->isGramUnit($product)) { return $product; } } return $products[0]; } /** * همان buy/sell اپ — کش live_price_api_payload پرشده از /api/price/last. * * @return array{buy: int, sell: int}|null */ private function resolveLiveTradePrices(): ?array { $display = app(ProductDisplayPriceService::class); $display->warmLivePriceCache(); $cached = Cache::get(ProductDisplayPriceService::LIVE_PRICE_CACHE_KEY); if (is_array($cached)) { $buy = (int) ($cached['buy'] ?? 0); $sell = (int) ($cached['sell'] ?? 0); if ($buy <= 0) { $buy = (int) ($cached['price_last'] ?? 0); } if ($buy > 0 && $sell > 0) { return ['buy' => $buy, 'sell' => $sell]; } } $base = $display->resolveAppBasePrice(); if ($base <= 0) { return null; } $provider = $this->config->priceProvider(); $priceSource = in_array($provider, ['montzar', 'zaarbox', 'main_product'], true) ? $provider : null; $flags = $provider === 'montzar' ? $this->config->montzarOpenFlags() : []; $trade = $this->config->buildTradeDisplayPrices( (float) $base, $priceSource, [ 'montzar_open_buy' => $flags['open_buy'] ?? true, 'montzar_open_sell' => $flags['open_sell'] ?? true, ] ); if ($trade['buy'] <= 0 && $trade['sell'] <= 0) { return null; } return [ 'buy' => (int) $trade['buy'], 'sell' => (int) $trade['sell'], ]; } /** * قیمت‌های نمایشی تابلو — همان منبع صفحهٔ اصلی اپ + price_channel_diff. * * @return array{gram_buy: string, gram_sell: string, mesghal_buy: string, mesghal_sell: string} */ private function computeDisplayPrices(): array { $channelDiff = (float) $this->config->getInt('price_channel_diff', 0); $mesghal = $this->changeMesghal(); $trade = $this->resolveLiveTradePrices(); if ($trade === null) { return [ 'gram_buy' => '', 'gram_sell' => '', 'mesghal_buy' => '', 'mesghal_sell' => '', ]; } // sell در API = خرید مشتری (پایین) — buy در API = فروش مشتری (بالا) — مثل CurrentPrice.vue $gramBuy = (float) $trade['sell'] - $channelDiff; $gramSell = (float) $trade['buy'] + $channelDiff; return [ 'gram_buy' => $this->formatPrice($gramBuy), 'gram_sell' => $this->formatPrice($gramSell), 'mesghal_buy' => $mesghal > 0 ? $this->formatPrice($gramBuy * $mesghal) : '', 'mesghal_sell' => $mesghal > 0 ? $this->formatPrice($gramSell * $mesghal) : '', ]; } /** * بدنهٔ قیمت محصولات (بدون فوتر) — مبنای تشخیص تغییر قیمت. * * @param array $products */ private function buildProductLines(array $products): string { if ($products === []) { return ''; } $channelDiff = (float) $this->config->getInt('price_channel_diff', 0); $diffBuy = (float) $this->config->displayBuyPriceDiffRial(); $diffSell = (float) $this->config->displaySellPriceDiffRial(); $mesghal = $this->changeMesghal(); $lines = []; foreach ($products as $product) { $base = (float) ($product->price ?? 0); // 🔴 خرید (مشتری می‌فروشد، پایین‌تر) — 🟢 فروش (مشتری می‌خرد، بالاتر) $low = $base - $diffSell - $channelDiff; $high = $base + $diffBuy + $channelDiff; $lines[] = '🛒 ' . trim((string) $product->title); $lines[] = '🔴 خرید ' . $this->formatPrice($low) . ' 🟢 فروش ' . $this->formatPrice($high); if ($this->isGramUnit($product) && $mesghal > 0) { $lines[] = '💰 هر مثقال: ' . $this->formatPrice($low * $mesghal) . ' / ' . $this->formatPrice($high * $mesghal); } $lines[] = ''; } return trim(implode("\n", $lines)); } private function buildFooterBlock(): string { $footer = $this->footerLines(); if ($footer === []) { return ''; } $lines = ['➖➖➖➖➖➖']; foreach ($footer as $line) { $lines[] = $line; } return implode("\n", $lines); } /** * @return array */ private function eligibleProducts(): array { $products = Product::query() ->where('active', 1) ->orderBy('id') ->get(); // قیمت زنده را روی مدل‌ها می‌گذارد (بدون UPDATE دیتابیس) تا پیام و تشخیص تغییر // مستقل از cron/tick و همیشه تازه باشد. try { $display = app(ProductDisplayPriceService::class); $display->warmLivePriceCache(); $display->enrichForApi($products); } catch (\Throwable $e) { Log::debug('PriceChannel enrich live price failed', ['message' => $e->getMessage()]); } $eligible = []; foreach ($products as $product) { if ((int) ($product->price ?? 0) <= 0) { continue; } if (! ($product->open_buy || $product->open_sell)) { continue; } if (! $this->config->isProductWithinTradingHours($product)) { continue; } $eligible[] = $product; } return $eligible; } private function isGramUnit(Product $product): bool { return trim((string) ($product->unit ?? '')) === 'گرم'; } /** قیمت را به واحد نمایش (تقسیم بر ۱۰٬۰۰۰) با جداکننده‌ی هزارگان می‌برد */ private function formatPrice(float $value): string { return number_format(max(0, $value) / 10000, 0); } private function changeMesghal(): float { $fromConfig = $this->config->get('change_mesghal'); $value = (float) ($fromConfig ?: config('change_mesghal', self::DEFAULT_CHANGE_MESGHAL)); return $value > 0 ? $value : self::DEFAULT_CHANGE_MESGHAL; } /** * خطوط پایانی — فقط مقادیر پرشده نمایش داده می‌شوند (آیدی چنل، شماره، لینک). * * @return array */ private function footerLines(): array { $lines = []; foreach (['price_channel_footer_id', 'price_channel_footer_phone', 'price_channel_footer_link'] as $key) { $value = trim((string) $this->config->getString($key, '')); if ($value !== '') { $lines[] = $value; } } return $lines; } /** * @param array $chatIds * @return array chatId => نتیجه (ok / خطا) */ private function dispatchToMessenger(string $messenger, string $token, array $chatIds, string $message): array { $results = []; foreach ($chatIds as $chatId) { try { $response = match ($messenger) { 'bale' => $this->sendBale($token, $chatId, $message), 'telegram' => $this->sendTelegram($token, $chatId, $message), 'rubika' => $this->sendRubika($token, $chatId, $message), default => null, }; $status = $response?->status() ?? 0; $ok = $response?->successful() ?? false; $results[$chatId] = $ok ? "ok ({$status})" : "failed ({$status}): " . trim((string) $response?->body()); if (! $ok) { Log::warning("PriceChannel {$messenger} send not ok", [ 'chat_id' => $chatId, 'status' => $status, 'body' => $response?->body(), ]); } } catch (\Throwable $e) { $results[$chatId] = 'exception: ' . $e->getMessage(); Log::warning("PriceChannel {$messenger} send failed", [ 'chat_id' => $chatId, 'message' => $e->getMessage(), ]); } } return $results; } private function sendBale(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { return Http::withOptions(Controller::httpClientOptions()) ->withoutVerifying() ->timeout(6) ->post("https://tapi.bale.ai/bot{$token}/sendMessage", [ 'chat_id' => $chatId, 'text' => $message, ]); } private function sendTelegram(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { // تلگرام مستقیم از سرور اغلب فیلتر است — از پروکسی wwwapp استفاده می‌شود (مثل کد قدیمی) return Http::withOptions(Controller::httpClientOptions()) ->timeout(6) ->get('https://api.wwwapp.ir/telegran_send.php', [ 'chatId' => $chatId, 'message' => $message, 'botToken' => $token, ]); } private function sendRubika(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response { return Http::withOptions(Controller::httpClientOptions()) ->withoutVerifying() ->timeout(6) ->post("https://botapi.rubika.ir/v3/{$token}/sendMessage", [ 'chat_id' => $chatId, 'text' => $message, ]); } /** وقتی قیمت عوض شده ولی cooldown مانده — یک بار بعد از اتمام cooldown دوباره تلاش کن */ private function scheduleDeferredBroadcast(int $delaySeconds): void { $delaySeconds = max(1, min($delaySeconds, 600)); if (! Cache::add(self::DEFERRED_BROADCAST_KEY, now()->timestamp, $delaySeconds + 5)) { return; } $dispatch = static function (): void { Cache::forget(self::DEFERRED_BROADCAST_KEY); app(self::class)->broadcastIfEligible(); }; if (app()->runningInConsole()) { // در tick/cron: تلاش مجدد در همان فرایند بعد از cooldown کافی نیست؛ // tick بعدی (۱۰ ثانیه) دوباره broadcastIfEligible را صدا می‌زند. return; } dispatch($dispatch)->delay(now()->addSeconds($delaySeconds)); } /** حداقل فاصلهٔ مجاز بین دو ارسال — Cache::add اتمی (TTL = min_seconds) */ private function acquireSendCooldown(bool $force = false): bool { $minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120)); if ($minSeconds === 0 || $force) { if ($minSeconds > 0) { Cache::put(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds); } return true; } return Cache::add(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds); } private function cooldownElapsed(): bool { return $this->cooldownSecondsRemaining() === 0; } private function cooldownSecondsRemaining(): int { $minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120)); if ($minSeconds === 0 || ! Cache::has(self::COOLDOWN_CACHE_KEY)) { return 0; } $lastSent = (int) Cache::get(self::COOLDOWN_CACHE_KEY, 0); if ($lastSent <= 0) { return 0; } return max(0, $minSeconds - (now()->timestamp - $lastSent)); } private function hasPriceChannelAccess(): bool { return $this->config->getBool('price_channel_access', false); } /** @return array */ private function parseList(string $raw): array { $parts = preg_split('/[,\n]+/', trim($raw)) ?: []; return array_values(array_filter(array_map('trim', $parts), static fn ($p) => $p !== '')); } }