Files
boursecu_server/app/Services/PriceChannelBroadcastService.php
T

880 lines
31 KiB
PHP
Raw Normal View History

2026-07-07 14:34:00 +03:30
<?php
namespace App\Services;
use App\Http\Controllers\Controller;
use App\Models\Product;
2026-07-08 12:45:14 +03:30
use Hekmatinasser\Verta\Verta;
2026-07-07 14:34:00 +03:30
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* ارسال قیمت لحظه‌ای به کانال‌های پیام‌رسان (بله، تلگرام، روبیکا).
*
* اصول (طبق rule ساعت کاری):
* • هیچ تنظیمی را بازنویسی نمی‌کند — فقط می‌خواند.
* • فقط پیام‌رسان‌هایی که ادمین انتخاب کرده و توکن + مقصد دارند ارسال می‌شود.
2026-07-08 13:40:43 +03:30
* • قیمت تابلو همان buy/sell کش‌شدهٔ /api/price/last است (مثل صفحهٔ اصلی اپ).
2026-07-07 14:34:00 +03:30
*/
class PriceChannelBroadcastService
{
private const COOLDOWN_CACHE_KEY = 'price_channel:last_sent_at';
2026-07-07 16:35:14 +03:30
private const SIGNATURE_CACHE_KEY = 'price_channel:last_signature';
2026-07-07 19:24:46 +03:30
private const SEND_LOCK_KEY = 'price_channel:send_lock';
2026-07-08 16:42:52 +03:30
private const DEFERRED_BROADCAST_KEY = 'price_channel:deferred_broadcast';
2026-07-07 14:34:00 +03:30
private const SUPPORTED_MESSENGERS = ['bale', 'telegram', 'rubika'];
2026-07-07 18:34:03 +03:30
/** وزن هر مثقال به گرم — ضریب تبدیل قیمت هر گرم به هر مثقال (مثقال = گرم × این عدد) */
2026-07-07 14:34:00 +03:30
private const DEFAULT_CHANGE_MESGHAL = 4.3318;
2026-07-08 12:45:14 +03:30
/** قالب پیش‌فرض پیام کانال — وقتی price_channel_message_text خالی باشد */
private const DEFAULT_MESSAGE_TEMPLATE = <<<'TXT'
سلام 👋
%عنوان
📅 %تاریخ 🕐 %ساعت
🔴 خرید گرم: %گرم_خرید
🟢 فروش گرم: %گرم_فروش
🔴 خرید مثقال: %مثقال_خرید
🟢 فروش مثقال: %مثقال_فروش
📞 %شماره
%آیدی
%لینک
TXT;
2026-07-07 14:34:00 +03:30
public function __construct(private readonly ConfigService $config)
{
}
2026-07-08 16:42:52 +03:30
/**
* صف ارسال کانال — همان لحظهٔ تغییر قیمت اپ (مثل SSE)، نه فقط cron.
*/
public function scheduleBroadcastIfEligible(): void
{
2026-08-03 11:51:16 +03:30
if (! $this->hasPriceChannelAccess()) {
return;
}
2026-07-08 16:42:52 +03:30
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();
}
2026-07-07 14:34:00 +03:30
/**
* نقطه‌ی ورود — بعد از تغییر قیمت صدا زده می‌شود.
* فقط وقتی قابلیت روشن است، پیام‌رسانی انتخاب شده و فاصله‌ی مجاز گذشته باشد ارسال می‌کند.
*/
public function broadcastIfEligible(): void
{
try {
2026-08-03 11:51:16 +03:30
if (! $this->hasPriceChannelAccess()) {
return;
}
2026-07-07 14:34:00 +03:30
if (! $this->config->getBool('price_channel_enabled', false)) {
return;
}
$targets = $this->resolveActiveTargets();
if ($targets === []) {
2026-07-07 16:35:14 +03:30
Log::debug('PriceChannel: enabled ولی هیچ مقصد فعالی نیست (توکن/مقصد را چک کن)');
2026-07-08 16:42:52 +03:30
2026-07-07 16:35:14 +03:30
return;
}
2026-07-07 19:24:46 +03:30
$lock = Cache::lock(self::SEND_LOCK_KEY, 30);
if (! $lock->get()) {
2026-07-07 18:21:36 +03:30
return;
}
2026-07-07 19:24:46 +03:30
try {
$products = $this->eligibleProducts();
2026-07-08 12:45:14 +03:30
$boardProduct = $this->resolveBoardProduct($products);
2026-07-08 13:40:43 +03:30
if ($boardProduct === null) {
2026-07-08 12:45:14 +03:30
Log::debug('PriceChannel: محصول تابلو (آبشده) واجد شرایطی نیست');
2026-07-08 16:42:52 +03:30
2026-07-07 19:24:46 +03:30
return;
}
2026-07-08 13:40:43 +03:30
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
Log::debug('PriceChannel: مظنه زنده تابلو در دسترس نیست');
2026-07-08 16:42:52 +03:30
2026-07-08 13:40:43 +03:30
return;
}
2026-07-08 16:42:52 +03:30
// اول امضا — بدون مصرف cooldown (باگ قبلی: cooldown قبل از امضا باعث می‌شد
// هر ۱۲۰ ثانیه یک «اسلات» بسوزد حتی وقتی قیمت عوض نشده یا ارسال ناموفق بود)
$signature = $this->broadcastSignature($trade);
2026-07-07 19:24:46 +03:30
if ($signature === Cache::get(self::SIGNATURE_CACHE_KEY)) {
return;
}
2026-07-08 16:42:52 +03:30
if (! $this->cooldownElapsed()) {
$this->scheduleDeferredBroadcast($this->cooldownSecondsRemaining() + 1);
Log::debug('PriceChannel: قیمت عوض شده ولی cooldown هنوز تمام نشده — ارسال به‌تأخیر افتاد', [
'remaining' => $this->cooldownSecondsRemaining(),
]);
return;
}
2026-07-08 12:45:14 +03:30
$message = $this->buildMessage($products, $boardProduct);
2026-07-07 19:24:46 +03:30
if ($message === '') {
return;
}
2026-07-08 16:42:52 +03:30
if (! $this->acquireSendCooldown()) {
return;
}
2026-07-07 19:24:46 +03:30
$results = [];
2026-07-08 16:42:52 +03:30
$anyOk = false;
2026-07-07 19:24:46 +03:30
foreach ($targets as $messenger => $target) {
$results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message);
2026-07-08 16:42:52 +03:30
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;
2026-07-07 19:24:46 +03:30
}
Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay());
Log::info('PriceChannel: قیمت به کانال ارسال شد', [
'targets' => array_keys($targets),
'products' => count($products),
2026-07-08 16:42:52 +03:30
'buy_rial' => $trade['buy'],
'sell_rial' => $trade['sell'],
2026-07-07 19:24:46 +03:30
'results' => $results,
]);
} finally {
$lock->release();
2026-07-07 16:35:14 +03:30
}
2026-07-07 14:34:00 +03:30
} catch (\Throwable $e) {
Log::warning('PriceChannelBroadcast failed', ['message' => $e->getMessage()]);
}
}
2026-07-07 19:24:46 +03:30
/**
2026-07-08 16:42:52 +03:30
* امضای ارسال — buy/sell خام API (ریال) + قالب؛ دقیق‌تر از رشتهٔ فرمت‌شدهٔ نمایشی.
*
* @param array{buy: int, sell: int}|null $trade
2026-07-07 19:24:46 +03:30
*/
2026-07-08 16:42:52 +03:30
private function broadcastSignature(?array $trade = null): string
2026-07-07 16:35:14 +03:30
{
2026-07-08 16:42:52 +03:30
$trade ??= $this->resolveLiveTradePrices();
if ($trade === null) {
return '';
}
$channelDiff = (int) $this->config->getInt('price_channel_diff', 0);
2026-07-08 12:45:14 +03:30
return md5(implode('|', [
2026-07-08 16:42:52 +03:30
(int) $trade['buy'] + $channelDiff,
(int) $trade['sell'] - $channelDiff,
2026-07-08 13:15:29 +03:30
$this->messageTemplate(),
2026-07-08 12:45:14 +03:30
]));
2026-07-07 16:35:14 +03:30
}
2026-07-08 13:15:29 +03:30
/**
* پس از ذخیرهٔ قالب در ادمین — یک بار فوری ارسال (بدون cooldown و بدون چک امضا).
*/
public function broadcastAfterTemplateChange(): void
{
try {
2026-08-03 11:51:16 +03:30
if (! $this->hasPriceChannelAccess()) {
return;
}
2026-07-08 13:15:29 +03:30
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);
2026-07-08 13:40:43 +03:30
if ($boardProduct === null) {
return;
}
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
2026-07-08 13:15:29 +03:30
return;
}
2026-07-08 16:42:52 +03:30
if (! $this->cooldownElapsed()) {
2026-07-08 15:51:20 +03:30
Log::debug('PriceChannel: قالب عوض شد ولی cooldown هنوز تمام نشده');
2026-07-08 16:42:52 +03:30
2026-07-08 15:51:20 +03:30
return;
}
2026-07-08 13:15:29 +03:30
$message = $this->buildMessage($products, $boardProduct);
if ($message === '') {
return;
}
2026-07-08 16:42:52 +03:30
if (! $this->acquireSendCooldown()) {
return;
2026-07-08 13:15:29 +03:30
}
2026-07-08 16:42:52 +03:30
$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);
2026-07-08 13:15:29 +03:30
Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay());
2026-07-08 15:51:20 +03:30
Log::info('PriceChannel: قالب پیام تغییر کرد — ارسال به کانال', [
2026-07-08 13:15:29 +03:30
'targets' => array_keys($targets),
'results' => $results,
]);
} finally {
$lock->release();
}
} catch (\Throwable $e) {
Log::warning('PriceChannel template broadcast failed', ['message' => $e->getMessage()]);
}
}
2026-07-07 16:14:11 +03:30
/**
* ارسال دستی (عیب‌یابی) — cooldown و شرط تغییر قیمت را نادیده می‌گیرد.
* وضعیت روشن/خاموش، انتخاب پیام‌رسان، توکن و مقصد همچنان لازم است.
*
* @return array<string, mixed>
*/
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 !== []) {
2026-07-08 15:51:20 +03:30
$this->acquireSendCooldown(true);
Cache::put(self::SIGNATURE_CACHE_KEY, $this->broadcastSignature(), now()->addDay());
2026-07-07 16:14:11 +03:30
}
return [
'sent' => $message !== '' && $targets !== [],
'results' => $results,
];
}
/**
* گزارش وضعیت — چرا ارسال می‌شود یا نمی‌شود.
*
* @return array<string, mixed>
*/
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,
2026-08-03 11:51:16 +03:30
'will_send' => $this->hasPriceChannelAccess() && $isSelected && $token !== '' && $chatIds !== [],
2026-07-07 16:14:11 +03:30
];
}
$message = $this->buildMessage();
2026-07-08 13:15:29 +03:30
$fromConfig = trim((string) $this->config->getString('price_channel_message_text', ''));
2026-07-08 13:40:43 +03:30
$trade = $this->resolveLiveTradePrices();
$display = $trade !== null ? $this->computeDisplayPrices() : null;
2026-07-07 16:14:11 +03:30
return [
2026-08-03 11:51:16 +03:30
'access' => $this->hasPriceChannelAccess(),
2026-07-07 16:14:11 +03:30
'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(),
2026-07-08 15:51:20 +03:30
'cooldown_seconds_remaining' => $this->cooldownSecondsRemaining(),
2026-07-08 16:42:52 +03:30
'signature_changed' => $trade !== null
&& $this->broadcastSignature($trade) !== Cache::get(self::SIGNATURE_CACHE_KEY),
2026-07-07 16:14:11 +03:30
'eligible_products' => count($this->eligibleProducts()),
'messengers' => $messengers,
'active_targets' => array_keys($this->resolveActiveTargets()),
2026-07-08 13:15:29 +03:30
'template_source' => $fromConfig !== '' ? 'db' : 'default',
2026-07-08 13:40:43 +03:30
'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'] ?? '',
2026-07-07 16:14:11 +03:30
'message_preview' => $message,
];
}
2026-07-07 14:34:00 +03:30
/**
* پیام‌رسان‌های قابل ارسال: انتخاب‌شده + توکن معتبر + حداقل یک مقصد.
*
* @return array<string, array{token: string, chat_ids: array<int, string>}>
*/
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<int, string> */
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";
}
/**
2026-07-08 12:45:14 +03:30
* ساخت متن پیام قیمت — قالب از price_channel_message_text (یا پیش‌فرض داخلی).
2026-07-07 14:34:00 +03:30
*/
2026-07-08 12:45:14 +03:30
public function buildMessage(?array $products = null, ?Product $boardProduct = null): string
2026-07-07 14:34:00 +03:30
{
2026-07-07 16:35:14 +03:30
$products = $products ?? $this->eligibleProducts();
2026-07-08 12:45:14 +03:30
$boardProduct = $boardProduct ?? $this->resolveBoardProduct($products);
2026-07-08 13:40:43 +03:30
if ($boardProduct === null) {
return '';
}
$trade = $this->resolveLiveTradePrices();
if ($trade === null || $trade['buy'] <= 0) {
2026-07-07 19:24:46 +03:30
return '';
}
2026-07-08 12:45:14 +03:30
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;
2026-07-07 19:24:46 +03:30
}
/**
2026-07-08 12:45:14 +03:30
* جایگزینی متغیرهای قالب.
* قیمت تابلو: %گرم_خرید %گرم_فروش %مثقال_خرید %مثقال_فروش
* زمان: %تاریخ %ساعت — اپ: %اسم_اپ %نام %عنوان
* تماس: %آیدی %شماره %لینک — اختیاری: %محصولات %فوتر
2026-07-07 19:24:46 +03:30
*
* @param array<int, Product> $products
*/
2026-07-08 12:45:14 +03:30
private function renderMessageTemplate(string $template, array $products, Product $boardProduct): string
{
$name = trim((string) $this->config->getString('name_fa', ''));
$title = $name !== '' ? "📊 قیمت لحظه‌ای {$name}" : '📊 قیمت لحظه‌ای';
2026-07-08 13:40:43 +03:30
$prices = $this->computeDisplayPrices();
2026-07-08 12:45:14 +03:30
$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<string, string> $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<int, Product> $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];
}
/**
2026-07-08 13:40:43 +03:30
* همان 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.
2026-07-08 12:45:14 +03:30
*
* @return array{gram_buy: string, gram_sell: string, mesghal_buy: string, mesghal_sell: string}
*/
2026-07-08 13:40:43 +03:30
private function computeDisplayPrices(): array
2026-07-08 12:45:14 +03:30
{
$channelDiff = (float) $this->config->getInt('price_channel_diff', 0);
$mesghal = $this->changeMesghal();
2026-07-08 13:40:43 +03:30
$trade = $this->resolveLiveTradePrices();
2026-07-08 12:45:14 +03:30
2026-07-08 13:40:43 +03:30
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;
2026-07-08 12:45:14 +03:30
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<int, Product> $products
*/
private function buildProductLines(array $products): string
2026-07-07 19:24:46 +03:30
{
2026-07-07 14:34:00 +03:30
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) {
2026-07-07 18:34:03 +03:30
$lines[] = '💰 هر مثقال: ' . $this->formatPrice($low * $mesghal)
. ' / ' . $this->formatPrice($high * $mesghal);
2026-07-07 14:34:00 +03:30
}
$lines[] = '';
}
return trim(implode("\n", $lines));
}
2026-07-08 12:45:14 +03:30
private function buildFooterBlock(): string
2026-07-07 14:34:00 +03:30
{
2026-07-08 12:45:14 +03:30
$footer = $this->footerLines();
if ($footer === []) {
return '';
}
2026-07-07 14:34:00 +03:30
2026-07-08 12:45:14 +03:30
$lines = ['➖➖➖➖➖➖'];
foreach ($footer as $line) {
$lines[] = $line;
}
2026-07-07 14:34:00 +03:30
2026-07-08 12:45:14 +03:30
return implode("\n", $lines);
2026-07-07 14:34:00 +03:30
}
/**
* @return array<int, Product>
*/
private function eligibleProducts(): array
{
$products = Product::query()
->where('active', 1)
->orderBy('id')
->get();
2026-07-07 18:21:36 +03:30
// قیمت زنده را روی مدل‌ها می‌گذارد (بدون 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()]);
}
2026-07-07 14:34:00 +03:30
$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<int, string>
*/
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;
}
2026-07-07 16:14:11 +03:30
/**
* @param array<int, string> $chatIds
* @return array<string, string> chatId => نتیجه (ok / خطا)
*/
private function dispatchToMessenger(string $messenger, string $token, array $chatIds, string $message): array
2026-07-07 14:34:00 +03:30
{
2026-07-07 16:14:11 +03:30
$results = [];
2026-07-07 14:34:00 +03:30
foreach ($chatIds as $chatId) {
try {
2026-07-07 16:14:11 +03:30
$response = match ($messenger) {
2026-07-07 14:34:00 +03:30
'bale' => $this->sendBale($token, $chatId, $message),
'telegram' => $this->sendTelegram($token, $chatId, $message),
'rubika' => $this->sendRubika($token, $chatId, $message),
default => null,
};
2026-07-07 16:14:11 +03:30
$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(),
]);
}
2026-07-07 14:34:00 +03:30
} catch (\Throwable $e) {
2026-07-07 16:14:11 +03:30
$results[$chatId] = 'exception: ' . $e->getMessage();
2026-07-07 14:34:00 +03:30
Log::warning("PriceChannel {$messenger} send failed", [
'chat_id' => $chatId,
'message' => $e->getMessage(),
]);
}
}
2026-07-07 16:14:11 +03:30
return $results;
2026-07-07 14:34:00 +03:30
}
2026-07-07 16:14:11 +03:30
private function sendBale(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
2026-07-07 14:34:00 +03:30
{
2026-08-26 21:47:09 +03:30
return Http::withOptions(Controller::httpClientOptions([], 6.0))
2026-07-07 14:34:00 +03:30
->withoutVerifying()
->timeout(6)
->post("https://tapi.bale.ai/bot{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
]);
}
2026-07-07 16:14:11 +03:30
private function sendTelegram(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
2026-07-07 14:34:00 +03:30
{
2026-08-03 13:47:01 +03:30
// تلگرام مستقیم از سرور اغلب فیلتر است — از پروکسی wwwapp استفاده می‌شود (مثل کد قدیمی)
2026-08-26 21:47:09 +03:30
return Http::withOptions(Controller::httpClientOptions([], 6.0))
2026-07-07 14:34:00 +03:30
->timeout(6)
2026-08-03 13:47:01 +03:30
->get('https://api.wwwapp.ir/telegran_send.php', [
'chatId' => $chatId,
'message' => $message,
'botToken' => $token,
2026-07-07 14:34:00 +03:30
]);
}
2026-07-07 16:14:11 +03:30
private function sendRubika(string $token, string $chatId, string $message): \Illuminate\Http\Client\Response
2026-07-07 14:34:00 +03:30
{
2026-08-26 21:47:09 +03:30
return Http::withOptions(Controller::httpClientOptions([], 6.0))
2026-07-07 14:34:00 +03:30
->withoutVerifying()
->timeout(6)
->post("https://botapi.rubika.ir/v3/{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
]);
}
2026-07-08 16:42:52 +03:30
/** وقتی قیمت عوض شده ولی 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));
}
2026-07-08 15:51:20 +03:30
/** حداقل فاصلهٔ مجاز بین دو ارسال — Cache::add اتمی (TTL = min_seconds) */
private function acquireSendCooldown(bool $force = false): bool
2026-07-07 14:34:00 +03:30
{
$minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120));
2026-07-08 15:51:20 +03:30
if ($minSeconds === 0 || $force) {
if ($minSeconds > 0) {
Cache::put(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds);
}
2026-07-07 14:34:00 +03:30
return true;
}
2026-07-08 15:51:20 +03:30
return Cache::add(self::COOLDOWN_CACHE_KEY, now()->timestamp, $minSeconds);
2026-07-07 14:34:00 +03:30
}
2026-07-08 15:51:20 +03:30
private function cooldownElapsed(): bool
2026-07-07 14:34:00 +03:30
{
2026-07-08 15:51:20 +03:30
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));
2026-07-07 14:34:00 +03:30
}
2026-08-03 11:51:16 +03:30
private function hasPriceChannelAccess(): bool
{
return $this->config->getBool('price_channel_access', false);
}
2026-08-03 13:47:01 +03:30
/** @return array<int, string> */
2026-07-07 14:34:00 +03:30
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 !== ''));
}
}