<?php namespace App\Models; use App\Library\RouletteWheel; use App\Library\SMSCounter; use App\Library\Tool; use Carbon\Carbon; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; use libphonenumber\NumberParseException; use libphonenumber\PhoneNumberUtil; use Throwable; use Illuminate\Support\Facades\Log as MailLog; /** * @method static where(string $string, string $uid) * @method static create(array $array) * @method static find($campaign_id) * @method static cursor() * @method static whereIn(string $string, mixed $ids) * @method static count() */ class Campaigns extends SendCampaignSMS { /** * Campaign status */ const STATUS_NEW = 'new'; const STATUS_QUEUED = 'queued'; const STATUS_SENDING = 'sending'; const STATUS_FAILED = 'failed'; const STATUS_DELIVERED = 'delivered'; const STATUS_CANCELLED = 'cancelled'; const STATUS_SCHEDULED = 'scheduled'; const STATUS_PROCESSING = 'processing'; /* * Campaign type */ const TYPE_ONETIME = 'onetime'; const TYPE_RECURRING = 'recurring'; // Campaign settings const WORKER_DELAY = 1; public static $serverPools = []; public static $senderIdPools = []; protected $sendingSevers = null; protected $senderIds = null; protected $currentSubscription; protected $fillable = [ 'user_id', 'campaign_name', 'message', 'media_url', 'language', 'gender', 'sms_type', 'upload_type', 'status', 'reason', 'api_key', 'cache', 'timezone', 'schedule_time', 'schedule_type', 'frequency_cycle', 'frequency_amount', 'frequency_unit', 'recurring_end', 'run_at', 'delivery_at', 'batch_id', 'running_pid', ]; protected $dates = ['created_at', 'updated_at', 'run_at', 'delivery_at', 'schedule_time', 'recurring_end']; /** * Bootstrap any application services. */ public static function boot() { parent::boot(); // Create uid when creating list. static::creating(function ($item) { // Create new uid $uid = uniqid(); while (self::where('uid', $uid)->count() > 0) { $uid = uniqid(); } $item->uid = $uid; }); } /** * get user * * @return BelongsTo * */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * get customer * * @return BelongsTo * */ public function customer(): BelongsTo { return $this->belongsTo(Customer::class, 'user_id'); } /** * get sending server * * @return BelongsTo * */ public function sendingServer(): BelongsTo { return $this->belongsTo(SendingServer::class); } /** * get reports * * @return HasMany */ public function reports(): HasMany { return $this->hasMany(Reports::class, 'campaign_id', 'id'); } /** * associate with contact groups * * @return HasMany */ public function contactList(): HasMany { return $this->hasMany(CampaignsList::class, 'campaign_id'); } /** * associate with recipients * * @return HasMany */ public function recipients(): HasMany { return $this->hasMany(CampaignsRecipients::class, 'campaign_id'); } /** * Get schedule recurs available values. * * @return array */ public static function scheduleCycleValues(): array { return [ 'daily' => [ 'frequency_amount' => 1, 'frequency_unit' => 'day', ], 'monthly' => [ 'frequency_amount' => 1, 'frequency_unit' => 'month', ], 'yearly' => [ 'frequency_amount' => 1, 'frequency_unit' => 'year', ], ]; } /** * Frequency time unit options. * * @return array */ public static function timeUnitOptions(): array { return [ ['value' => 'day', 'text' => 'day'], ['value' => 'week', 'text' => 'week'], ['value' => 'month', 'text' => 'month'], ['value' => 'year', 'text' => 'year'], ]; } /** * contact count * * @param false $cache * * @return mixed|null */ public function contactCount(bool $cache = false) { if ($cache) { return $this->readCache('ContactCount', 0); } $list_ids = $this->contactList()->select('contact_list_id')->cursor()->pluck('contact_list_id')->all(); $list_count = Contacts::whereIn('group_id', $list_ids)->where('status', 'subscribe')->count(); $recipients_count = $this->recipients()->count(); return $list_count + $recipients_count; } /** * show delivered count * * @param false $cache * * @return int */ public function deliveredCount(bool $cache = false): int { if ($cache) { return $this->readCache('DeliveredCount', 0); } return $this->reports()->where('campaign_id', $this->id)->where('status', 'like', '%Delivered%')->count(); } /** * show failed count * * @param false $cache * * @return int */ public function failedCount(bool $cache = false): int { if ($cache) { return $this->readCache('FailedDeliveredCount', 0); } return $this->reports()->where('campaign_id', $this->id)->where('status', 'not like', '%Delivered%')->count(); } /** * show not delivered count * * @param false $cache * * @return int */ public function notDeliveredCount(bool $cache = false): int { if ($cache) { return $this->readCache('NotDeliveredCount', 0); } return $this->reports()->where('campaign_id', $this->id)->where('status', 'like', '%Sent%')->count(); } public function nextScheduleDate($startDate, $interval, $intervalCount) { return match ($interval) { 'month' => $startDate->addMonthsNoOverflow($intervalCount), 'day' => $startDate->addDay($intervalCount), 'week' => $startDate->addWeek($intervalCount), 'year' => $startDate->addYearsNoOverflow($intervalCount), default => null, }; } /** * Update Campaign cached data. * * @param null $key */ public function updateCache($key = null) { // cache indexes $index = [ 'DeliveredCount' => function ($campaign) { return $campaign->deliveredCount(); }, 'FailedDeliveredCount' => function ($campaign) { return $campaign->failedCount(); }, 'NotDeliveredCount' => function ($campaign) { return $campaign->notDeliveredCount(); }, 'ContactCount' => function ($campaign) { return $campaign->contactCount(true); }, ]; // retrieve cached data $cache = json_decode($this->cache, true); if (is_null($cache)) { $cache = []; } if (is_null($key)) { foreach ($index as $key => $callback) { $cache[$key] = $callback($this); } } else { $callback = $index[$key]; $cache[$key] = $callback($this); } // write back to the DB $this->cache = json_encode($cache); $this->save(); } /** * Retrieve Campaign cached data. * * @param $key * @param null $default * * @return mixed */ public function readCache($key, $default = null) { $cache = json_decode($this->cache, true); if (is_null($cache)) { return $default; } if (array_key_exists($key, $cache)) { if (is_null($cache[$key])) { return $default; } else { return $cache[$key]; } } else { return $default; } } /** * get active plan sending servers * * @return mixed */ public function activePlanSendingServers() { return PlansSendingServer::where('plan_id', $this->user->customer->activeSubscription()->plan_id); } /** * get active customer sending servers * * @return mixed */ public function activeCustomerSendingServers() { return SendingServer::where('user_id', $this->user->id)->where('status', true); } public function getCurrentSubscription() { if (empty($this->currentSubscription)) { $this->currentSubscription = $this->user->customer->activeSubscription(); } return $this->currentSubscription; } public function getSendingServers() { if ( ! is_null($this->sendingSevers)) { return $this->sendingSevers; } $sending_server_id = CampaignsSendingServer::where('campaign_id', $this->id)->first()->sending_server_id; $this->sendingSevers = SendingServer::find($sending_server_id); return $this->sendingSevers; } /** * get sender ids * * @return array */ public function getSenderIds(): array { if ( ! is_null($this->senderIds)) { return $this->senderIds; } $result = CampaignsSenderid::where('campaign_id', $this->id)->cursor()->map(function ($sender_id) { return [$sender_id->sender_id, $sender_id->id]; })->all(); $assoc = []; foreach ($result as $server) { [$key, $fitness] = $server; $assoc[$key] = $fitness; } $this->senderIds = $assoc; return $this->senderIds; } /** * mark campaign as queued to processing */ public function running() { $this->status = self::STATUS_PROCESSING; $this->run_at = Carbon::now(); $this->save(); } /** * mark campaign as failed * * @param null $reason */ public function failed($reason = null) { $this->status = self::STATUS_FAILED; $this->reason = $reason; $this->save(); } /** * set campaign warning * * @param null $reason */ public function warning($reason = null) { $this->reason = $reason; $this->save(); } public function preparedDataToSend() { try { // clean up the tracker to prevent the log file from growing very big $this->user->customer->cleanupQuotaTracker(); // set campaign queued to processing $this->running(); // Reset max_execution_time so that command can run for a long time without being terminated Tool::resetMaxExecutionTime(); $this->singleProcess(); } catch (Exception $exception) { $this->failed($exception->getMessage()); } catch (Throwable $e) { $this->failed($e->getMessage()); } } /** * @return $this */ public function refreshStatus(): Campaigns { $campaign = self::find($this->id); $this->status = $campaign->status; $this->save(); return $this; } /** * Mark the campaign as delivered. */ public function delivered() { $this->status = self::STATUS_DELIVERED; $this->delivery_at = Carbon::now(); $this->save(); } /** * Mark the campaign as delivered. */ public function cancelled() { $this->status = self::STATUS_CANCELLED; $this->save(); } /** * Mark the campaign as processing. */ public function processing() { $this->status = self::STATUS_PROCESSING; $this->running_pid = getmypid(); $this->run_at = Carbon::now(); $this->save(); } /** * render sms with tag * * @param $msg * @param $data * * @return string|string[] */ public function renderSMS($msg, $data) { preg_match_all('~{(.*?)}~s', $msg, $datas); foreach ($datas[1] as $value) { if (array_key_exists($value, $data)) { $msg = preg_replace("/\b$value\b/u", $data[$value], $msg); } else { $msg = str_ireplace($value, '', $msg); } } return str_ireplace(["{", "}"], '', $msg); } /** * get coverage * * @return array */ public function getCoverage(): array { $data = []; $plan_coverage = PlansCoverageCountries::where('plan_id', $this->user->customer->activeSubscription()->plan->id)->cursor(); foreach ($plan_coverage as $coverage) { $data[$coverage->country->country_code] = json_decode($coverage->options, true); } return $data; } /** * send campaign * * @throws NumberParseException * @throws Throwable */ public function singleProcess($partition = null) { $prepareForTemplateTag = []; $contactsData = []; $cutting_array = []; $total_list_contacts = 0; $check_list_count = CampaignsList::where('campaign_id', $this->id)->count(); if ($check_list_count > 0) { $list = CampaignsList::where('campaign_id', $this->id)->select('contact_list_id')->cursor(); $list_id = $list->pluck('contact_list_id')->all(); Contacts::whereIn('group_id', $list_id)->where('status', 'subscribe')->chunk(5000, function ($lines) use (&$contactsData) { foreach ($lines as $line) { $data = $line->toArray(); foreach ($line->custom_fields as $field) { $data[$field->tag] = $field->value; } $contactsData[] = $data; } }); $total_list_contacts = count($contactsData); } if (CampaignsRecipients::where('campaign_id', $this->id)->count() > 0) { CampaignsRecipients::where('campaign_id', $this->id)->select('recipient as phone')->chunk(500, function ($lines) use (&$contactsData, &$total_list_contacts) { foreach ($lines as $line) { $data = $line->toArray(); $data['id'] = $total_list_contacts++; $contactsData[] = $data; } }); } $collection = collect($contactsData); $contact_count = $this->contactCount($this->cache); $cutting_system = $this->user->customer->getOption('cutting_system'); $cost = 0; $total_unit = 0; if ($cutting_system == 'yes' && $this->user->customer->getOption('cutting_value') != 0) { $cutting_value = $this->user->customer->getOption('cutting_value'); $cutting_unit = $this->user->customer->getOption('cutting_unit'); $cutting_logic = $this->user->customer->getOption('cutting_logic'); if ($cutting_unit == 'percentage') { $cutting_value = ($cutting_value / 100) * $contact_count; } $cutting_amount = (int) round($cutting_value); if ($cutting_logic == 'random') { $cutting_array = $collection->random($cutting_amount)->all(); } if ($cutting_logic == 'start') { $cutting_array = $collection->slice(0, $cutting_amount)->all(); } if ($cutting_logic == 'end') { $cutting_array = $collection->slice(-$cutting_amount)->all(); } } $insertData = Tool::check_diff_multi($collection->all(), $cutting_array); $sending_server = $this->getSendingServers(); $coverage = $this->getCoverage(); collect($cutting_array)->chunk(1000)->each(function ($lines) use (&$prepareForTemplateTag, $cost, &$total_unit, $sending_server, $coverage) { $check_sender_id = $this->getSenderIds(); foreach ($lines as $line) { $message = $this->renderSMS($this->message, $line); $sms_type = $this->sms_type; $phone = str_replace(['+', '(', ')', '-', ' '], '', $line['phone']); if (count($check_sender_id) > 0) { $sender_id = $this->pickSenderIds(); } else { $sender_id = null; } if (Tool::validatePhone($phone)) { try { $phoneUtil = PhoneNumberUtil::getInstance(); $phoneNumberObject = $phoneUtil->parse('+'.$phone); $country_code = $phoneNumberObject->getCountryCode(); if (is_array($coverage) && array_key_exists($country_code, $coverage)) { if ($this->sms_type == 'plain' || $this->sms_type == 'unicode') { $cost = $coverage[$country_code]['plain_sms']; } if ($this->sms_type == 'voice') { $cost = $coverage[$country_code]['voice_sms']; } if ($this->sms_type == 'mms') { $cost = $coverage[$country_code]['mms_sms']; } if ($this->sms_type == 'whatsapp') { $cost = $coverage[$country_code]['whatsapp_sms']; } $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = $cost * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = 'Delivered'; } else { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = "Permission to send an SMS has not been enabled for the region indicated by the 'To' number: ".$line['phone']; } } catch (NumberParseException $exception) { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = $exception->getMessage(); } } else { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = __('locale.customer.invalid_phone_number', ['phone' => $phone]); } if ($this->user->customer->getOption('send_spam_message') == 'no') { $spamWords = SpamWord::all()->filter(function ($spamWord) use ($message) { if (true === str_contains(strtolower($message), strtolower($spamWord->word))) { return true; } return false; }); if ($spamWords->count()) { $preparedData['status'] = 'Your message contains spam words.'; } } $preparedData['campaign_id'] = $this->id; $preparedData['sending_server'] = $sending_server; $prepareForTemplateTag[] = $preparedData; } }); collect($insertData)->chunk(1000)->each(function ($lines) use (&$prepareForTemplateTag, $cost, &$total_unit, $sending_server, $coverage) { $check_sender_id = $this->getSenderIds(); foreach ($lines as $line) { $message = $this->renderSMS($this->message, $line); $sms_type = $this->sms_type; if (count($check_sender_id) > 0) { $sender_id = $this->pickSenderIds(); } else { $sender_id = null; } $phone = str_replace(['+', '(', ')', '-', ' '], '', $line['phone']); if (Tool::validatePhone($phone)) { try { $phoneUtil = PhoneNumberUtil::getInstance(); $phoneNumberObject = $phoneUtil->parse('+'.$phone); $country_code = $phoneNumberObject->getCountryCode(); if (is_array($coverage) && array_key_exists($country_code, $coverage)) { if ($this->sms_type == 'plain' || $this->sms_type == 'unicode') { $cost = $coverage[$country_code]['plain_sms']; } if ($this->sms_type == 'voice') { $cost = $coverage[$country_code]['voice_sms']; } if ($this->sms_type == 'mms') { $cost = $coverage[$country_code]['mms_sms']; } if ($this->sms_type == 'whatsapp') { $cost = $coverage[$country_code]['whatsapp_sms']; } $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = $cost * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = null; } else { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = "Permission to send an SMS has not been enabled for the region indicated by the 'To' number: ".$line['phone']; } } catch (NumberParseException $exception) { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = $exception->getMessage(); } } else { $sms_counter = new SMSCounter(); $message_data = $sms_counter->count($message); $sms_count = $message_data->messages; $price = 1 * $sms_count; $total_unit += (int) $price; $preparedData['id'] = $line['id']; $preparedData['user_id'] = $this->user_id; $preparedData['phone'] = $line['phone']; $preparedData['sender_id'] = $sender_id; $preparedData['message'] = $message; $preparedData['sms_type'] = $sms_type; $preparedData['cost'] = (int) $price; $preparedData['status'] = __('locale.customer.invalid_phone_number', ['phone' => $phone]); } if ($this->user->customer->getOption('send_spam_message') == 'no') { $spamWords = SpamWord::all()->filter(function ($spamWord) use ($message) { if (true === str_contains(strtolower($message), strtolower($spamWord->word))) { return true; } return false; }); if ($spamWords->count()) { $preparedData['status'] = 'Your message contains spam words.'; } } $preparedData['campaign_id'] = $this->id; $preparedData['sending_server'] = $sending_server; $prepareForTemplateTag[] = $preparedData; } }); if ($this->user->sms_unit != '-1' && $total_unit > $this->user->sms_unit) { $this->failed(sprintf("Campaign `%s` (%s) halted, customer exceeds sms credit", $this->campaign_name, $this->uid)); sleep(60); } else { $user = User::find($this->user->id); if ($user->sms_unit != '-1') { DB::transaction(function () use ($user, $total_unit) { $remaining_balance = $user->sms_unit - $total_unit; $user->lockForUpdate(); $user->update(['sms_unit' => $remaining_balance]); }); } try { $failed_cost = 0; $this->processing(); collect($prepareForTemplateTag)->sortBy('id')->values()->chunk(3000)->each(function ($sendData) use (&$failed_cost) { foreach ($sendData as $data) { $status = null; if ($this->sms_type == 'plain' || $this->sms_type == 'unicode') { $status = $this->sendPlainSMS($data); } if ($this->sms_type == 'voice') { $data['language'] = $this->language; $data['gender'] = $this->gender; $status = $this->sendVoiceSMS($data); } if ($this->sms_type == 'mms') { $data['media_url'] = $this->media_url; $status = $this->sendMMS($data); } if ($this->sms_type == 'whatsapp') { if (isset($this->media_url)) { $data['media_url'] = $this->media_url; } $status = $this->sendWhatsApp($data); } if (substr_count($status, 'Delivered') == 1) { $this->updateCache('DeliveredCount'); } else { $failed_cost += $data['cost']; $this->updateCache('FailedDeliveredCount'); } } }); unset($user); $user = User::find($this->user->id); if ($user->sms_unit != '-1') { DB::transaction(function () use ($user, $failed_cost) { $remaining_balance = $user->sms_unit + $failed_cost; $user->lockForUpdate(); $user->update(['sms_unit' => $remaining_balance]); }); } $this->delivered(); } catch (Exception $exception) { $this->failed($exception->getMessage()); } finally { self::resetServerPools(); $this->updateCache(); $this->delivered(); } } } /** * reset server pools */ public static function resetServerPools() { self::$serverPools = []; } public function pickSendingServer() { $selection = $this->getSendingServers(); // do not raise an exception, just wait if sending servers are available but exceeding sending limit $blacklisted = []; while (true) { $id = RouletteWheel::generate($selection); if (empty(self::$serverPools[$id])) { $server = SendingServer::find($id); if ($server->custom) { $server['custom_info'] = $server->customSendingServer; } $server->cleanupQuotaTracker(); self::$serverPools[$id] = $server; } if (self::$serverPools[$id]->overQuota()) { // log every 60 seconds if ( ! array_key_exists($id, $blacklisted) || time() - $blacklisted[$id] >= 60) { $blacklisted[$id] = time(); $this->warning(sprintf('Sending server `%s` exceeds sending limit, skipped', self::$serverPools[$id]->name)); } // if all sending servers are blacklisted if (sizeof($blacklisted) == sizeof($selection)) { $this->warning(__('locale.campaigns.sending_server_exceed_sending_limit')); sleep(30); } continue; } return self::$serverPools[$id]; } } /** * pick sender id * * */ public function pickSenderIds(): int|string { $selection = array_values(array_flip($this->getSenderIds())); shuffle($selection); while (true) { $element = array_pop($selection); if ($element) { return (string) $element; } } } /** * get sms type * * @return string */ public function getSMSType(): string { $sms_type = $this->sms_type; if ($sms_type == 'plain') { return '<span class="badge bg-primary text-uppercase me-1 mb-1">'.__('locale.labels.plain').'</span>'; } if ($sms_type == 'unicode') { return '<span class="badge bg-primary text-uppercase me-1 mb-1">'.__('locale.labels.unicode').'</span>'; } if ($sms_type == 'voice') { return '<span class="badge bg-success text-uppercase me-1 mb-1">'.__('locale.labels.voice').'</span>'; } if ($sms_type == 'mms') { return '<span class="badge bg-info text-uppercase me-1 mb-1">'.__('locale.labels.mms').'</span>'; } if ($sms_type == 'whatsapp') { return '<span class="badge bg-warning text-uppercase mb-1">'.__('locale.labels.whatsapp').'</span>'; } return '<span class="badge bg-danger text-uppercase mb-1">'.__('locale.labels.invalid').'</span>'; } /** * get sms type * * @return string */ public function getCampaignType(): string { $sms_type = $this->schedule_type; if ($sms_type == 'onetime') { return '<div> <span class="badge badge-light-info text-uppercase me-1 mb-1">'.__('locale.labels.scheduled').'</span> <p class="text-muted">'.Tool::customerDateTime($this->schedule_time).'</p> </div>'; } if ($sms_type == 'recurring') { return '<div> <span class="badge badge-light-success text-uppercase me-1 mb-1">'.__('locale.labels.recurring').'</span> <p class="text-muted">'.__('locale.labels.every').' '.$this->displayFrequencyTime().'</p> <p class="text-muted">'.__('locale.labels.next_schedule_time').': '.Tool::formatDateTime($this->schedule_time).'</p> <p class="text-muted">'.__('locale.labels.end_time').': '.Tool::formatDateTime($this->recurring_end).'</p> </div>'; } return '<span class="badge badge-light-primary text-uppercase me-1 mb-1">'.__('locale.labels.normal').'</span>'; } /** * Display frequency time * * @return string */ public function displayFrequencyTime(): string { return $this->frequency_amount.' '.Tool::getPluralParse($this->frequency_unit, $this->frequency_amount); } /** * get campaign status * * @return string */ public function getStatus(): string { $status = $this->status; if ($status == self::STATUS_FAILED || $status == self::STATUS_CANCELLED) { return '<div> <span class="badge bg-danger text-uppercase me-1 mb-1">'.__('locale.labels.'.$status).'</span> <p class="text-muted" data-toggle="tooltip" data-placement="top" title="'.$this->reason.'">'.str_limit($this->reason, 40).'</p> </div>'; } if ($status == self::STATUS_SENDING || $status == self::STATUS_PROCESSING) { return '<div> <span class="badge bg-primary text-uppercase mr-1 mb-1">'.__('locale.labels.'.$status).'</span> <p class="text-muted">'.__('locale.labels.run_at').': '.Tool::customerDateTime($this->run_at).'</p> </div>'; } if ($status == self::STATUS_SCHEDULED) { return '<span class="badge bg-info text-uppercase mr-1 mb-1">'.__('locale.labels.scheduled').'</span>'; } if ($status == self::STATUS_NEW || $status == self::STATUS_QUEUED) { return '<span class="badge bg-primary text-uppercase mr-1 mb-1">'.__('locale.labels.'.$status).'</span>'; } return '<div> <span class="badge bg-success text-uppercase mr-1 mb-1">'.__('locale.labels.delivered').'</span> <p class="text-muted">'.__('locale.labels.delivered_at').': '.Tool::customerDateTime($this->delivery_at).'</p> </div>'; } /** * get route key by uid * * @return string */ public function getRouteKeyName(): string { return 'uid'; } /** * make ready to send * * @return void */ public function queued() { $this->status = self::STATUS_QUEUED; $this->save(); } /** * Check if the campaign is ready to start. */ public function isQueued() { return $this->status == self::STATUS_QUEUED; } /** * get another running process * * @return bool */ public function occupiedByOtherAnotherProcess() { if ( ! function_exists('posix_getpid')) { return false; } return ( ! is_null($this->running_pid) && posix_getpgid($this->running_pid)); } /** * start campaign * * @return void */ public function run() { try { if ( ! $this->refreshStatus()->isQueued()) { MailLog::info("Campaign ID: {$this->id} is not ready or already started"); return; } if ($this->refreshStatus()->occupiedByOtherAnotherProcess()) { MailLog::info("Campaign ID: {$this->id} is occupied by another process PID: {$this->running_pid}"); return; } $this->processing(); MailLog::info('Starting campaign `'.$this->name.'`'); Tool::resetMaxExecutionTime(); // Only run multi-process if pcntl is enabled $processes = (int) $this->user->customer->getOption('max_process'); if (extension_loaded('pcntl') && function_exists('pcntl_fork') && $processes > 1) { MailLog::info('Run in multi-process mode'); $this->runMultiProcesses(); } else { MailLog::info('Run in single-process mode'); $this->singleProcess(); } MailLog::info('Finish campaign `'.$this->name.'`'); } catch (Exception $ex) { $this->failed($ex->getMessage()); MailLog::error('Starting campaign failed. '.$ex->getMessage()); } catch (Throwable $e) { $this->failed($e->getMessage()); MailLog::error('Starting campaign failed. '.$e->getMessage()); } } /** * Start the campaign, using PHP fork() to launch multiple processes. * * @return void */ public function runMultiProcesses() { // processes to fork $count = (int) $this->user->customer->getOption('max_process'); $count = ($count > 2) ? 2 : $count; MailLog::info("Forking {$count} process(es)"); $parentPid = $this->running_pid; $children = []; for ($i = 0; $i < $count; $i += 1) { $pid = pcntl_fork(); if ( ! $pid) { DB::reconnect('mysql'); MailLog::info(sprintf('Start child process %s of %s (forked from %s)', $i + 1, $count, $parentPid)); sleep(self::WORKER_DELAY); $partition = [$i, $count]; try { $this->singleProcess($partition); } catch (NumberParseException|Throwable $e) { $this->failed($e->getMessage()); } exit($i + 1); // end child process } else { $children[] = $pid; } } // wait for child processes to finish foreach ($children as $ignored) { $pid = pcntl_wait($status); if (pcntl_wifexited($status)) { $code = pcntl_wexitstatus($status); MailLog::info("Child process $pid finished, status code: $code"); } else { MailLog::warning("Child process $pid did not normally exit"); $this->failed("Child process $pid did not normally exit"); } } // after all child processes are done $this->refreshStatus(); if ($this->status == self::STATUS_DELIVERED) { $this->delivered(); } } }