620 lines
23 KiB
PHP
620 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Product;
|
|
use Hekmatinasser\Verta\Verta;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* ارسال قیمت لحظهای به کانالهای پیامرسان (بله، تلگرام، روبیکا).
|
|
*
|
|
* اصول (طبق rule ساعت کاری):
|
|
* • هیچ تنظیمی را بازنویسی نمیکند — فقط میخواند.
|
|
* • فقط پیامرسانهایی که ادمین انتخاب کرده و توکن + مقصد دارند ارسال میشود.
|
|
* • قیمت هر محصول از قیمت پایه ذخیرهشده + کارمزد نمایش همان اپ ساخته میشود.
|
|
*/
|
|
class PriceChannelBroadcastService
|
|
{
|
|
private const COOLDOWN_CACHE_KEY = 'price_channel:last_sent_at';
|
|
|
|
private const SIGNATURE_CACHE_KEY = 'price_channel:last_signature';
|
|
|
|
private const SEND_LOCK_KEY = 'price_channel:send_lock';
|
|
|
|
private const SUPPORTED_MESSENGERS = ['bale', 'telegram', 'rubika'];
|
|
|
|
/** وزن هر مثقال به گرم — ضریب تبدیل قیمت هر گرم به هر مثقال (مثقال = گرم × این عدد) */
|
|
private const DEFAULT_CHANGE_MESGHAL = 4.3318;
|
|
|
|
/** قالب پیشفرض پیام کانال — وقتی price_channel_message_text خالی باشد */
|
|
private const DEFAULT_MESSAGE_TEMPLATE = <<<'TXT'
|
|
سلام 👋
|
|
%عنوان
|
|
📅 %تاریخ 🕐 %ساعت
|
|
|
|
🔴 خرید گرم: %گرم_خرید
|
|
🟢 فروش گرم: %گرم_فروش
|
|
🔴 خرید مثقال: %مثقال_خرید
|
|
🟢 فروش مثقال: %مثقال_فروش
|
|
|
|
📞 %شماره
|
|
%آیدی
|
|
%لینک
|
|
TXT;
|
|
|
|
public function __construct(private readonly ConfigService $config)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* نقطهی ورود — بعد از تغییر قیمت صدا زده میشود.
|
|
* فقط وقتی قابلیت روشن است، پیامرسانی انتخاب شده و فاصلهی مجاز گذشته باشد ارسال میکند.
|
|
*/
|
|
public function broadcastIfEligible(): void
|
|
{
|
|
try {
|
|
if (! $this->config->getBool('price_channel_enabled', false)) {
|
|
return;
|
|
}
|
|
|
|
$targets = $this->resolveActiveTargets();
|
|
if ($targets === []) {
|
|
Log::debug('PriceChannel: enabled ولی هیچ مقصد فعالی نیست (توکن/مقصد را چک کن)');
|
|
return;
|
|
}
|
|
|
|
// قفل — دو فراخوان همزمان (مثلاً دو force_refresh پشتسرهم) را سریالی میکند تا
|
|
// هر دو قبل از ذخیرهٔ markSent/امضا، cooldown را «گذشته» و امضا را «قدیمی» نبینند و
|
|
// پیام تکراری نفرستند. اگر قفل دست کسی است، همین حالا بیارسال برمیگردیم.
|
|
$lock = Cache::lock(self::SEND_LOCK_KEY, 30);
|
|
if (! $lock->get()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// شرط ۱: از آخرین «ارسال واقعی» حداقل price_channel_min_seconds گذشته باشد.
|
|
// چون فقط روی ارسال واقعی markSent میکنیم، یک بررسیِ بیارسال تایمر را ریست نمیکند.
|
|
if (! $this->cooldownElapsed()) {
|
|
return;
|
|
}
|
|
|
|
$products = $this->eligibleProducts();
|
|
$boardProduct = $this->resolveBoardProduct($products);
|
|
if ($boardProduct === null || (int) ($boardProduct->price ?? 0) <= 0) {
|
|
Log::debug('PriceChannel: محصول تابلو (آبشده) واجد شرایطی نیست');
|
|
return;
|
|
}
|
|
|
|
// شرط ۲: قیمت تابلو نسبت به آخرین ارسال واقعاً تغییر کرده باشد
|
|
$signature = $this->priceSignature($boardProduct);
|
|
if ($signature === Cache::get(self::SIGNATURE_CACHE_KEY)) {
|
|
return;
|
|
}
|
|
|
|
$message = $this->buildMessage($products, $boardProduct);
|
|
if ($message === '') {
|
|
return;
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($targets as $messenger => $target) {
|
|
$results[$messenger] = $this->dispatchToMessenger($messenger, $target['token'], $target['chat_ids'], $message);
|
|
}
|
|
|
|
// فقط بعد از ارسال واقعی: تایمر cooldown و امضای قیمت بهروزرسانی میشوند
|
|
$this->markSent();
|
|
Cache::put(self::SIGNATURE_CACHE_KEY, $signature, now()->addDay());
|
|
|
|
Log::info('PriceChannel: قیمت به کانال ارسال شد', [
|
|
'targets' => array_keys($targets),
|
|
'products' => count($products),
|
|
'results' => $results,
|
|
]);
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('PriceChannelBroadcast failed', ['message' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* امضای قیمت — روی «بدنهٔ نمایشیِ» پیام (بدون هدر/ساعت) گرفته میشود تا دقیقاً همان چیزی
|
|
* که کاربر میبیند مبنای تشخیص تغییر باشد؛ اختلاف چند ریالیِ زیرِ آستانهٔ نمایش پیام تکراری نمیسازد.
|
|
*
|
|
* @param array<int, Product> $products
|
|
*/
|
|
private function priceSignature(Product $boardProduct): string
|
|
{
|
|
$prices = $this->computeDisplayPrices($boardProduct);
|
|
|
|
return md5(implode('|', [
|
|
$prices['gram_buy'],
|
|
$prices['gram_sell'],
|
|
$prices['mesghal_buy'],
|
|
$prices['mesghal_sell'],
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* ارسال دستی (عیبیابی) — 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 !== []) {
|
|
$this->markSent();
|
|
$products = $this->eligibleProducts();
|
|
$boardProduct = $this->resolveBoardProduct($products);
|
|
if ($boardProduct !== null) {
|
|
Cache::put(self::SIGNATURE_CACHE_KEY, $this->priceSignature($boardProduct), now()->addDay());
|
|
}
|
|
}
|
|
|
|
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,
|
|
'will_send' => $isSelected && $token !== '' && $chatIds !== [],
|
|
];
|
|
}
|
|
|
|
$message = $this->buildMessage();
|
|
|
|
return [
|
|
'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(),
|
|
'eligible_products' => count($this->eligibleProducts()),
|
|
'messengers' => $messengers,
|
|
'active_targets' => array_keys($this->resolveActiveTargets()),
|
|
'message_preview' => $message,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* پیامرسانهای قابل ارسال: انتخابشده + توکن معتبر + حداقل یک مقصد.
|
|
*
|
|
* @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";
|
|
}
|
|
|
|
/**
|
|
* ساخت متن پیام قیمت — قالب از 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 || (int) ($boardProduct->price ?? 0) <= 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<int, Product> $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($boardProduct);
|
|
|
|
$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];
|
|
}
|
|
|
|
/**
|
|
* قیمتهای نمایشی تابلو (گرم + مثقال) — همان منطق اپ + price_channel_diff.
|
|
*
|
|
* @return array{gram_buy: string, gram_sell: string, mesghal_buy: string, mesghal_sell: string}
|
|
*/
|
|
private function computeDisplayPrices(Product $product): array
|
|
{
|
|
$channelDiff = (float) $this->config->getInt('price_channel_diff', 0);
|
|
$diffBuy = (float) $this->config->displayBuyPriceDiffRial();
|
|
$diffSell = (float) $this->config->displaySellPriceDiffRial();
|
|
$mesghal = $this->changeMesghal();
|
|
$base = (float) ($product->price ?? 0);
|
|
|
|
// 🔴 خرید (مشتری میفروشد، پایینتر) — 🟢 فروش (مشتری میخرد، بالاتر)
|
|
$gramBuy = $base - $diffSell - $channelDiff;
|
|
$gramSell = $base + $diffBuy + $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<int, Product> $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<int, Product>
|
|
*/
|
|
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<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;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $chatIds
|
|
* @return array<string, string> 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
|
|
{
|
|
return Http::withOptions(Controller::httpClientOptions())
|
|
->timeout(6)
|
|
->post("https://api.telegram.org/bot{$token}/sendMessage", [
|
|
'chat_id' => $chatId,
|
|
'text' => $message,
|
|
]);
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
/** حداقل فاصلهی مجاز بین دو ارسال — جلوگیری از سیل پیام در هر تیک ۱۰ثانیهای */
|
|
private function cooldownElapsed(): bool
|
|
{
|
|
$minSeconds = max(0, $this->config->getInt('price_channel_min_seconds', 120));
|
|
if ($minSeconds === 0) {
|
|
return true;
|
|
}
|
|
|
|
$lastSent = Cache::get(self::COOLDOWN_CACHE_KEY);
|
|
if ($lastSent === null) {
|
|
return true;
|
|
}
|
|
|
|
return now()->timestamp - (int) $lastSent >= $minSeconds;
|
|
}
|
|
|
|
private function markSent(): void
|
|
{
|
|
Cache::put(self::COOLDOWN_CACHE_KEY, now()->timestamp, now()->addDay());
|
|
}
|
|
|
|
/** @return array<int, string> */
|
|
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 !== ''));
|
|
}
|
|
}
|