PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
← All changes | app/Services/Helper.php +1486 -26 1.5.52.4.0 View file →
@@ -3,12 +3,18 @@
3 3 namespace FluentSupport\App\Services;
4 4
5 5 use FluentSupport\App\App;
6 6 use FluentSupport\App\Models\Agent;
7 +use FluentSupport\App\Models\Attachment;
8 +use FluentSupport\App\Models\Ticket;
9 +use FluentSupport\App\Models\Conversation;
7 10 use FluentSupport\App\Models\Customer;
8 11 use FluentSupport\App\Models\MailBox;
9 12 use FluentSupport\App\Models\Meta;
13 +use FluentSupport\App\Models\AIActivityLogs;
10 14 use FluentSupport\App\Models\Person;
15 +use FluentSupport\App\Models\Product;
16 +use FluentSupport\App\Services\Includes\UploadService;
11 17 use FluentSupport\App\Services\EmailNotification\Settings;
12 18 use FluentSupport\Framework\Support\Arr;
13 19
14 20 /**
@@ -30,9 +36,9 @@
30 36 /**
31 37 * Get agent information by user id
32 38 * The function will get user id as parameter or get id from session and return agent information
33 39 * @param null $userId
34 - * @return false
40 + * @return false | Agent
35 41 */
36 42 public static function getAgentByUserId($userId = null)
37 43 {
38 44 if ($userId === null) {
@@ -40,9 +46,8 @@
40 46 }
41 47 if (!$userId) {
42 48 return false;
43 49 }
44 -
45 50 return Agent::where('user_id', $userId)->first();
46 51 }
47 52
48 53 /**
@@ -90,8 +95,23 @@
90 95 ]);
91 96 }
92 97
93 98 /**
99 + * This function will return custom ticket status group
100 + *
101 + * @return mixed
102 + */
103 + public static function changeableTicketStatuses()
104 + {
105 + $ticketStatus = static::ticketStatusGroups();
106 +
107 + unset($ticketStatus['all']);
108 + unset($ticketStatus['open']);
109 +
110 + return apply_filters('fluent_support/changeable_ticket_statuses', $ticketStatus);
111 + }
112 +
113 + /**
94 114 * This function will return ticket status list
95 115 *
96 116 * @return mixed
97 117 */
@@ -149,16 +169,17 @@
149 169 public static function getFileUploadMessage()
150 170 {
151 171 $mimeHeadings = self::getAcceptedMimeHeadings();
152 172 $settings = (new Settings())->globalBusinessSettings();
153 - $maxFileSize = absint($settings['max_file_size']);
173 + $maxFileSize = floatval($settings['max_file_size']);
154 174
155 - return sprintf(__('Supported Types: %s and max file size: %dMB', 'fluent-support'), implode(', ', $mimeHeadings), $maxFileSize);
175 + // translators: %1$s is a comma-separated list of supported file types, %2$.01f is the maximum file size in megabytes
176 + return sprintf(__('Supported Types: %1$s and max file size: %2$.01fMB', 'fluent-support'), implode(', ', $mimeHeadings), $maxFileSize);
156 177 }
157 178
158 179 public static function getMimeGroups()
159 180 {
160 - return [
181 + return apply_filters('fluent_support/mime_groups', [
161 182 'images' => [
162 183 'title' => __('Photos', 'fluent-support'),
163 184 'mimes' => [
164 185 'image/gif',
@@ -205,9 +226,9 @@
205 226 'application/json',
206 227 'application/jsonml+json'
207 228 ]
208 229 ]
209 - ];
230 + ]);
210 231 }
211 232
212 233 /**
213 234 * getOption method will return settings using key
@@ -223,9 +244,9 @@
223 244 ->where('key', $key)
224 245 ->first();
225 246
226 247 if ($data) {
227 - $value = maybe_unserialize($data->value);
248 + $value = static::safeUnserialize($data->value);
228 249 if ($value) {
229 250 return $value;
230 251 }
231 252 }
@@ -262,8 +283,16 @@
262 283 'value' => maybe_serialize($value)
263 284 ]);
264 285 }
265 286
287 + /** @internal Not called from core controllers — available for Pro/hook usage. */
288 + public static function deleteOption($key)
289 + {
290 + return Meta::where('object_type', 'option')
291 + ->where('key', $key)
292 + ->delete();
293 + }
294 +
266 295 /**
267 296 * getIntegrationOption method will return the integration settings by integration key
268 297 * @param $key
269 298 * @param string $default
@@ -275,9 +304,9 @@
275 304 ->where('key', $key)
276 305 ->first();
277 306
278 307 if ($data) {
279 - $value = maybe_unserialize($data->value);
308 + $value = static::safeUnserialize($data->value);
280 309 if ($value) {
281 310 return $value;
282 311 }
283 312 }
@@ -313,10 +342,10 @@
313 342
314 343 public static function getTicketViewUrl($ticket)
315 344 {
316 345 $baseUrl = self::getPortalBaseUrl();
317 -
318 - return $baseUrl . '/#/ticket/' . $ticket->id . '/view';
346 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
347 + return $baseUrl . '/#/ticket/' . $ticketNumber . '/view';
319 348 }
320 349
321 350 public static function getTicketViewSignedUrl($ticket)
322 351 {
@@ -324,18 +353,139 @@
324 353 return self::getTicketViewUrl($ticket);
325 354 }
326 355
327 356 $baseUrl = self::getPortalBaseUrl();
357 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
328 358
329 359 $baseUrl = add_query_arg([
330 360 'fs_view' => 'ticket',
331 361 'support_hash' => $ticket->hash,
332 - 'ticket_id' => $ticket->id
362 + 'ticket_id' => $ticketNumber,
333 363 ], $baseUrl);
334 364
335 - return $baseUrl . '#/ticket/' . $ticket->id . '/view';
365 + return $baseUrl . '#/ticket/' . $ticketNumber . '/view';
336 366 }
337 367
368 + public static function saveOpenAIData($objectType, $key, $data)
369 + {
370 + $serializedData = maybe_serialize($data);
371 +
372 + $previousValue = Meta::where('object_type', $objectType)->first();
373 +
374 + if ($previousValue) {
375 + return Meta::where('object_type', $objectType)->update([
376 + 'value' => $serializedData
377 + ]);
378 + } else {
379 + return Meta::insert([
380 + 'object_type' => $objectType,
381 + 'key' => $key,
382 + 'value' => $serializedData
383 + ]);
384 + }
385 +
386 + }
387 +
388 + public static function saveAIProviderSettings(array $data)
389 + {
390 + if (!empty($data['api_key'])) {
391 + $data['api_key'] = static::encryptApiKey($data['api_key']);
392 + }
393 +
394 + $serializedData = maybe_serialize($data);
395 + $previous = Meta::where('object_type', '_fs_ai_provider_settings')->first();
396 +
397 + if ($previous) {
398 + return Meta::where('object_type', '_fs_ai_provider_settings')->update([
399 + 'value' => $serializedData,
400 + ]);
401 + }
402 +
403 + return Meta::insert([
404 + 'object_type' => '_fs_ai_provider_settings',
405 + 'key' => '_fs_ai_provider_data',
406 + 'value' => $serializedData,
407 + ]);
408 + }
409 +
410 + public static function getAIProviderSettings(): array
411 + {
412 + $record = Meta::where('object_type', '_fs_ai_provider_settings')->first();
413 +
414 + if (!$record) {
415 + $record = Meta::where('object_type', '_fs_openai_settings')->first();
416 + }
417 +
418 + if (!$record) {
419 + return [];
420 + }
421 +
422 + $settings = static::safeUnserialize($record->value);
423 +
424 + if (empty($settings) || !is_array($settings)) {
425 + return [];
426 + }
427 +
428 + if (!isset($settings['provider'])) {
429 + $settings['provider'] = 'openai';
430 + }
431 +
432 + if (!empty($settings['api_key'])) {
433 + $settings['api_key'] = static::decryptApiKey($settings['api_key']);
434 + }
435 +
436 + return $settings;
437 + }
438 +
439 + public static function encryptApiKey(string $apiKey): string
440 + {
441 + if (!extension_loaded('openssl')) {
442 + return $apiKey;
443 + }
444 +
445 + $cipher = 'AES-256-CBC';
446 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
447 + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher));
448 + $encrypted = openssl_encrypt($apiKey, $cipher, $key, 0, $iv);
449 +
450 + if ($encrypted === false) {
451 + return $apiKey;
452 + }
453 +
454 + return 'fsai:' . base64_encode($iv . $encrypted);
455 + }
456 +
457 + public static function decryptApiKey(string $value): string
458 + {
459 + if (!extension_loaded('openssl') || strncmp($value, 'fsai:', 5) !== 0) {
460 + return $value;
461 + }
462 +
463 + $cipher = 'AES-256-CBC';
464 + $decoded = base64_decode(substr($value, 5));
465 + $ivLen = openssl_cipher_iv_length($cipher);
466 +
467 + if (strlen($decoded) <= $ivLen) {
468 + return $value;
469 + }
470 +
471 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
472 + $iv = substr($decoded, 0, $ivLen);
473 + $decrypted = openssl_decrypt(substr($decoded, $ivLen), $cipher, $key, 0, $iv);
474 +
475 + return $decrypted !== false ? $decrypted : $value;
476 + }
477 +
478 + public static function authorizeChatGPTAPIKey($data)
479 + {
480 + return wp_remote_get('https://api.openai.com/v1/models', [
481 + 'headers' => [
482 + 'Authorization' => 'Bearer ' . $data['api_key'],
483 + 'Content-Type' => 'application/json'
484 + ]
485 + ]);
486 + }
487 +
338 488 public static function isPublicSignedTicketEnabled()
339 489 {
340 490 $businessSettings = self::getBusinessSettings();
341 491
@@ -354,22 +504,61 @@
354 504 */
355 505 public static function getPortalBaseUrl()
356 506 {
357 507 $businessSettings = self::getBusinessSettings();
358 - $baseUrl = get_permalink($businessSettings['portal_page_id']);
359 - $baseUrl = rtrim($baseUrl, '/\\');
360 - return apply_filters('fluent_support/portal_base_url', $baseUrl);
508 + $portalType = Arr::get($businessSettings, 'ticket_link_portal', 'default');
509 + $baseUrl = null;
510 +
511 + if (self::isPortalActive($portalType)) {
512 + if ($portalType === 'woocommerce' && function_exists('wc_get_endpoint_url') && function_exists('wc_get_page_permalink')) {
513 + $accountUrl = wc_get_page_permalink('myaccount');
514 + if ($accountUrl && $accountUrl !== '#') {
515 + $baseUrl = wc_get_endpoint_url('support-tickets', '', $accountUrl);
516 + }
517 + } elseif ($portalType === 'fluent_cart') {
518 + $baseUrl = \FluentCart\App\Services\URL::getCustomerDashboardUrl('fluent-support') ?: null;
519 + } elseif ($portalType === 'fluent_community') {
520 + $baseUrl = \FluentCommunity\App\Services\Helper::baseUrl('support/') ?: null;
521 + }
522 + }
523 +
524 + if (!$baseUrl) {
525 + $baseUrl = get_permalink(absint(Arr::get($businessSettings, 'portal_page_id')));
526 + }
527 +
528 + return apply_filters('fluent_support/portal_base_url', rtrim((string) $baseUrl, '/\\'));
361 529 }
362 530
531 + public static function isPortalActive($portalType)
532 + {
533 + if ($portalType === 'woocommerce') {
534 + return defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && defined('WC_PLUGIN_FILE');
535 + }
536 +
537 + if ($portalType === 'fluent_cart') {
538 + return defined('FLUENTCART_VERSION');
539 + }
540 +
541 + if ($portalType === 'fluent_community') {
542 + return defined('FLUENT_COMMUNITY_PLUGIN_VERSION');
543 + }
544 +
545 + return false;
546 + }
547 +
363 548 public static function getPortalAdminBaseUrl()
364 549 {
365 550 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
366 551 }
367 552
368 - public static function getBusinessSettings()
553 + public static function getBusinessSettings($key = null)
369 554 {
370 555 static $settings;
371 556
557 + if ($settings && $key) {
558 + return Arr::get($settings, $key);
559 + }
560 +
372 561 if ($settings) {
373 562 return $settings;
374 563 }
375 564
@@ -374,11 +563,19 @@
374 563 }
375 564
376 565 $settings = (new Settings())->globalBusinessSettings();
377 566
567 + if ($key) {
568 + return Arr::get($settings, $key);
569 + }
378 570 return $settings;
379 571 }
380 572
573 + public static function isAgentFeedbackEnabled()
574 + {
575 + return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
576 + }
577 +
381 578 public static function getTicketMeta($ticketId, $key, $default = '')
382 579 {
383 580 $data = Meta::where('object_type', 'ticket_meta')
384 581 ->where('key', $key)
@@ -385,9 +582,9 @@
385 582 ->where('object_id', $ticketId)
386 583 ->first();
387 584
388 585 if ($data) {
389 - $value = maybe_unserialize($data->value);
586 + $value = static::safeUnserialize($data->value);
390 587 if ($value) {
391 588 return $value;
392 589 }
393 590 }
@@ -423,15 +620,15 @@
423 620 ->table('posts')
424 621 ->select(['ID', 'post_title'])
425 622 ->where('post_type', 'page')
426 623 ->where('post_status', 'publish')
427 - ->orderBy('ID', 'DESC')
624 + ->latest('ID')
428 625 ->get();
429 626 $formattedPages = [];
430 627 foreach ($pages as $page) {
431 628 $formattedPages[] = [
432 - 'id' => strval($page->ID),
433 - 'title' => $page->post_title
629 + 'id' => intval($page->ID),
630 + 'title' => $page->post_title ?: __('(no title)', 'fluent-support')
434 631 ];
435 632 }
436 633 return $formattedPages;
437 634 }
@@ -443,26 +640,68 @@
443 640 if ($mailbox) {
444 641 return $mailbox;
445 642 }
446 643
447 - return MailBox::orderBy('id', 'ASC')->first();
644 + return MailBox::oldest('id')->first();
448 645 }
449 646
450 647 public static function getCurrentAgent()
451 648 {
452 - return Agent::where('user_id', get_current_user_id())->first();
649 + // If user is logged in then return the agent by user id.
650 + // This `get_current_user_id` function is WP function and
651 + // it returns user id if user is logged in.
652 + if (get_current_user_id()) {
653 + return Agent::where('user_id', get_current_user_id())->first();
654 + }
453 655 }
454 656
455 657 public static function getCurrentCustomer()
456 658 {
457 - return Customer::where('user_id', get_current_user_id())->first();
659 + // If user is logged in then return the customer by user id.
660 + // This `get_current_user_id` function is WP function and
661 + // it returns user id if user is logged in.
662 + if (get_current_user_id()) { //if user is logged in
663 + // Ordered for the same reason getCurrentPerson() and
664 + // Customer::getCustomerFromData() are: the user_id column carries a
665 + // plain index, not a unique one, so one account can have more than
666 + // one customer record. Without an order the record that wins is
667 + // whatever the storage engine returns first, and this helper could
668 + // disagree with the other two about which record a request belongs
669 + // to.
670 + return Customer::where('user_id', get_current_user_id())
671 + ->orderBy('id', 'ASC')
672 + ->first();
673 + }
458 674 }
459 675
460 676 public static function getCurrentPerson()
461 677 {
462 - return Person::where('user_id', get_current_user_id())->first();
678 + // If user is logged in then return the person(agent/customer) by user id.
679 + // This `get_current_user_id` function is WP function and
680 + // it returns user id if user is logged in.
681 + if (get_current_user_id()) {
682 + return Person::where('user_id', get_current_user_id())
683 + ->orderBy('id', 'ASC')
684 + ->first();
685 + }
686 + return null;
463 687 }
464 688
689 + public static function getCustomerByID($customerid)
690 + {
691 + return Customer::where('id', $customerid)->first();
692 + }
693 +
694 + /** @internal Not called from core controllers — available for Pro/hook usage. */
695 + public static function sanitizeOrderValue($orderType = '')
696 + {
697 + $orderBys = ['ASC', 'DESC'];
698 +
699 + $orderType = trim(strtoupper((string)($orderType ?? '')));
700 +
701 + return in_array($orderType, $orderBys) ? $orderType : 'DESC';
702 + }
703 +
465 704 public static function getFluentCRMTagConfig()
466 705 {
467 706 if (!defined('FLUENTCRM')) {
468 707 return [
@@ -467,8 +706,9 @@
467 706 if (!defined('FLUENTCRM')) {
468 707 return [
469 708 'can_add_tags' => false,
470 709 'tags' => [],
710 + 'lists' => [],
471 711 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
472 712 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
473 713 ];
474 714 }
@@ -476,19 +716,27 @@
476 716 $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
477 717
478 718 $canAddTags = apply_filters('fluent_support/can_user_add_tags_to_customer', $canAddTags);
479 719 $crmTags = [];
720 + $crmLists = [];
480 721 if ($canAddTags) {
481 - $crmTags = \FluentCrm\App\Models\Tag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
722 + $crmTags = \FluentCrm\App\Models\Tag::select(['id', 'title'])->oldest('title')->get();
723 + $crmLists = \FluentCrm\App\Models\Lists::select(['id', 'title'])->oldest('title')->get();
482 724 }
483 725
484 - return [
726 + $crmConfigs = [
485 727 'can_add_tags' => $canAddTags,
486 728 'tags' => $crmTags,
729 + 'lists' => $crmLists,
487 730 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
488 731 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
489 732 ];
490 733
734 + if (defined('FLUENTCRM')) {
735 + $crmConfigs['contacts'] = []; //(new \FluentCrm\App\Models\Subscriber)->get();
736 + }
737 +
738 + return $crmConfigs;
491 739 }
492 740
493 741 /**
494 742 * getFluentCrmContactData method will get information from fluent crm using user email
@@ -503,8 +751,9 @@
503 751 //Get contact info from FluentCRM using customer email
504 752 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
505 753 if ($contact) {
506 754 $tags = $contact->tags;
755 + $lists = $contact->lists;
507 756 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
508 757 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
509 758
510 759 //Return contact data
@@ -514,8 +763,9 @@
514 763 'last_name' => $contact->last_name,
515 764 'full_name' => $contact->full_name,
516 765 'name_mismatch' => $contact->full_name != $customer->full_name,
517 766 'tags' => $tags,
767 + 'lists' => $lists,
518 768 'status' => $contact->status,
519 769 'stats' => $contact->stats(),
520 770 'view_url' => $crmProfileUrl
521 771 ];
@@ -521,6 +771,1216 @@
521 771 ];
522 772 }
523 773
524 774 return false;
775 + }
776 +
777 + public static function openAIIntegrationStatus()
778 + {
779 + $settings = static::getAIProviderSettings();
780 +
781 + if (($settings['enabled'] ?? 'yes') === 'no') {
782 + return false;
783 + }
784 +
785 + return !empty($settings['api_key']);
786 + }
787 +
788 + public static function fluentBotIntegrationStatus()
789 + {
790 + // Include object_id and order by id desc to always read the latest row; the
791 + // write path (saveFluentBotSettings) does not prune siblings, so duplicates
792 + // may exist and a non-deterministic read can return stale state.
793 + $meta = Meta::where([
794 + 'object_type' => 'fluent_bot_settings',
795 + 'object_id' => 1,
796 + 'key' => '_fs_fluent_bot_config'
797 + ])->orderByDesc('id')->first();
798 +
799 + $settings = static::safeUnserialize($meta ? $meta->value : null);
800 +
801 + return isset($settings['isEnabled']) && filter_var($settings['isEnabled'], FILTER_VALIDATE_BOOLEAN);
802 + }
803 +
804 +
805 + public static function showTicketSummaryAdminBar()
806 + {
807 + $data = self::getOption('global_business_settings');
808 +
809 + if ($data && isset($data["enable_admin_bar_summary"]) && $data["enable_admin_bar_summary"] == 'yes') {
810 + return true;
811 + }
812 +
813 + return false;
814 + }
815 +
816 + public static function generateMessageID($email)
817 + {
818 + if (!$email || !is_string($email)) {
819 + return false;
820 + }
821 +
822 + $emailParts = explode('@', $email);
823 + if (count($emailParts) != 2) {
824 + return false;
825 + }
826 +
827 + $emailDomain = $emailParts[1];
828 + try {
829 + return sprintf(
830 + "<%s.%s@%s>",
831 + base_convert((int)microtime(true), 10, 36),
832 + base_convert(bin2hex(openssl_random_pseudo_bytes(8)), 16, 36),
833 + $emailDomain
834 + );
835 + } catch (\Exception $exception) {
836 + return false;
837 + }
838 + }
839 +
840 + public static function getExportOptions()
841 + {
842 + $data = [
843 + 'Agent First Name' => __('Agent First Name', 'fluent-support'),
844 + 'Agent Last Name' => __('Agent Last Name', 'fluent-support'),
845 + 'Agent Full Name' => __('Agent Full Name', 'fluent-support'),
846 + 'Responses' => __('Responses', 'fluent-support'),
847 + 'Interactions' => __('Interactions', 'fluent-support'),
848 + 'Open Tickets' => __('Open Tickets', 'fluent-support'),
849 + 'Closed' => __('Closed', 'fluent-support'),
850 + 'Waiting Tickets' => __('Waiting Tickets', 'fluent-support'),
851 + 'Average Waiting' => __('Average Waiting', 'fluent-support'),
852 + 'Max Waiting' => __('Max Waiting', 'fluent-support'),
853 + ];
854 +
855 + if (Helper::isAgentFeedbackEnabled()) {
856 + $data['Likes'] = __('Likes', 'fluent-support');
857 + $data['Dislikes'] = __('Dislikes', 'fluent-support');
858 + }
859 +
860 + return $data;
861 + }
862 +
863 + public static function getAuthProvider()
864 + {
865 + if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
866 + $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
867 + if ($settings['enabled'] == 'yes') {
868 + return 'fluent_auth';
869 + }
870 + }
871 +
872 + return 'fluent_support';
873 + }
874 +
875 + /** @internal Not called from core controllers — available for Pro/hook usage. */
876 + public static function getDriversKey(){
877 + return [
878 + 'dropbox_settings',
879 + 'google_drive_settings',
880 + 'cloudflare_r2_settings',
881 + 'amazon_s3_settings',
882 + 'local'
883 + ];
884 + }
885 +
886 +
887 + public static function getUploadDriverKey()
888 + {
889 + if (!defined('FLUENTSUPPORTPRO')) {
890 + return 'local';
891 + }
892 +
893 + $driver = self::getOption('file_upload_driver');
894 +
895 + if ($driver) {
896 + return $driver;
897 + }
898 +
899 + // Now guess the driver and save it
900 +
901 + // check if dropbox is enabled
902 + $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
903 + if ($dropboxSettings) {
904 + $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
905 + ->where('key', 'dropbox_settings')
906 + ->where('value', 'yes')
907 + ->first();
908 +
909 + if ($dropBoxEnabled) {
910 + $driver = 'dropbox';
911 + self::updateOption('file_upload_driver', $driver);
912 + return $driver;
913 + }
914 + }
915 +
916 + // check if google drive is enabled
917 + $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
918 +
919 + if ($googleDriveSettings) {
920 + $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
921 + ->where('key', 'google_drive_settings')
922 + ->where('value', 'yes')
923 + ->first();
924 +
925 + if ($googleDriveEnabled) {
926 + $driver = 'google_drive';
927 + self::updateOption('file_upload_driver', $driver);
928 + return $driver;
929 + }
930 + }
931 +
932 + // check if cloudflare r2 is enabled
933 + $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
934 +
935 + if ($cloudflareR2Settings) {
936 + $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
937 + ->where('key', 'cloudflare_r2_settings')
938 + ->where('value', 'yes')
939 + ->first();
940 +
941 + if ($cloudflareR2Enabled) {
942 + $driver = 'cloudflare_r2';
943 + self::updateOption('file_upload_driver', $driver);
944 + return $driver;
945 + }
946 + }
947 +
948 + // check if amazon s3 is enabled
949 + $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
950 +
951 + if ($amazonS3Settings) {
952 + $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
953 + ->where('key', 'amazon_s3_settings')
954 + ->where('value', 'yes')
955 + ->first();
956 +
957 + if ($amazonS3Enabled) {
958 + $driver = 'amazon_s3';
959 + self::updateOption('file_upload_driver', $driver);
960 + return $driver;
961 + }
962 + }
963 +
964 + self::updateOption('file_upload_driver', 'local');
965 + return 'local';
966 + }
967 +
968 + public static function getIntegrationStatuses()
969 + {
970 + $connections = [
971 + 'woocommerce' => [
972 + 'title' => __('WooCommerce', 'fluent-support'),
973 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/woocommerce.png',
974 + 'is_integrated' => defined('WC_PLUGIN_FILE'),
975 + 'description' => __('The most popular e-commerce platform for WordPress', 'fluent-support'),
976 + 'doc_url' => 'https://docs.fluentsupport.com/woocommerce-integration',
977 + ],
978 + 'fluent-cart' => [
979 + 'title' => __('FluentCart', 'fluent-support'),
980 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-cart.webp',
981 + 'is_integrated' => defined('FLUENTCART_VERSION'),
982 + 'description' => __('A New Era of eCommerce with WordPress', 'fluent-support'),
983 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcart-integration',
984 + ],
985 + 'lifter-lms' => [
986 + 'title' => __('LifterLMS', 'fluent-support'),
987 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
988 + 'is_integrated' => defined('LLMS_PLUGIN_FILE'),
989 + 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
990 + 'doc_url' => 'https://docs.fluentsupport.com/lifterlms-integration',
991 + ],
992 + 'slack' => [
993 + 'title' => __('Slack', 'fluent-support'),
994 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
995 + 'is_integrated' => self::getFSIntegrationStatus('slack_settings'),
996 + 'description' => __('Business communication platform designed to scale', 'fluent-support'),
997 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-slack',
998 + ],
999 + 'pm-pro' => [
1000 + 'title' => __('Paid Memberships Pro', 'fluent-support'),
1001 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
1002 + 'is_integrated' => defined('PMPRO_VERSION'),
1003 + 'description' => __('The ultimate platform for any member-focused business', 'fluent-support'),
1004 + 'doc_url' => 'https://docs.fluentsupport.com/paid-membership-pro-integration',
1005 + ],
1006 + 'tutor-lms' => [
1007 + 'title' => __('Tutor LMS', 'fluent-support'),
1008 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
1009 + 'is_integrated' => defined('TUTOR_VERSION'),
1010 + 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
1011 + 'doc_url' => 'https://docs.fluentsupport.com/tutorlms-integration',
1012 + ],
1013 + 'telegram' => [
1014 + 'title' => __('Telegram', 'fluent-support'),
1015 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
1016 + 'is_integrated' => self::getFSIntegrationStatus('telegram_settings'),
1017 + 'description' => __('Business communication platform designed for security', 'fluent-support'),
1018 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-telegram',
1019 + ],
1020 + 'fluent-crm' => [
1021 + 'title' => __('FluentCRM', 'fluent-support'),
1022 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
1023 + 'is_integrated' => defined('FLUENTCRM'),
1024 + 'description' => __('Self-hosted email and marketing automation for WordPress', 'fluent-support'),
1025 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcrm-integration',
1026 + ],
1027 + 'fluent-community' => [
1028 + 'title' => __('FluentCommunity', 'fluent-support'),
1029 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-community.png',
1030 + 'is_integrated' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
1031 + 'description' => __('Build and manage vibrant online communities with integrated LMS features directly within WordPress.', 'fluent-support'),
1032 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-community-integration',
1033 + ],
1034 + 'fluent-forms' => [
1035 + 'title' => __('Fluent Forms', 'fluent-support'),
1036 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-forms.png',
1037 + 'is_integrated' => defined('FLUENTFORM'),
1038 + 'description' => __('A robust form plugin suitable for any business', 'fluent-support'),
1039 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-form-integration',
1040 + ],
1041 + 'buddy-boss' => [
1042 + 'title' => __('BuddyBoss', 'fluent-support'),
1043 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
1044 + 'is_integrated' => defined('BP_PLUGIN_DIR'),
1045 + 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
1046 + 'doc_url' => 'https://docs.fluentsupport.com/buddyboss-integration'
1047 + ],
1048 + 'discord' => [
1049 + 'title' => __('Discord', 'fluent-support'),
1050 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
1051 + 'is_integrated' => self::getFSIntegrationStatus('discord_settings'),
1052 + 'description' => __('Business communication platform designed for tech', 'fluent-support'),
1053 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-discord',
1054 + ],
1055 + 'wishlist-member' => [
1056 + 'title' => __('WishList Member', 'fluent-support'),
1057 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
1058 + 'is_integrated' => defined('WLM3_PLUGIN_VERSION'),
1059 + 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
1060 + 'doc_url' => 'https://docs.fluentsupport.com/wishlist-member-integration',
1061 + ],
1062 + 'easy-digital-downloads' => [
1063 + 'title' => __('Easy Digital Downloads', 'fluent-support'),
1064 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
1065 + 'is_integrated' => class_exists('\Easy_Digital_Downloads'),
1066 + 'description' => __('The ultimate WordPress platform for digital products', 'fluent-support'),
1067 + 'doc_url' => 'https://docs.fluentsupport.com/edd-integration',
1068 + ],
1069 + 'restrict-content-pro' => [
1070 + 'title' => __('Restrict Content pro', 'fluent-support'),
1071 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
1072 + 'is_integrated' => class_exists('\Restrict_Content_Pro' ),
1073 + 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
1074 + 'doc_url' => 'https://docs.fluentsupport.com/restrict-content-pro-integration',
1075 + ],
1076 + 'better-docs' => [
1077 + 'title' => __('BetterDocs', 'fluent-support'),
1078 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
1079 + 'is_integrated' => false,
1080 + 'description' => __('The standard plugin for knowledge base and documentation', 'fluent-support'),
1081 + 'doc_url' => 'https://docs.fluentsupport.com/betterdocs-integration',
1082 + ],
1083 + 'whatsapp' => [
1084 + 'title' => __('WhatsApp', 'fluent-support'),
1085 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
1086 + 'is_integrated' => self::getFSIntegrationStatus('twilio_settings'),
1087 + 'description' => __('Business communication platform designed for privacy', 'fluent-support'),
1088 + 'doc_url' => 'https://docs.fluentsupport.com/whatsapp-integration-via-twilio',
1089 + ],
1090 + 'paymattic' => [
1091 + 'title' => __('Paymattic', 'fluent-support'),
1092 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/paymattic.png',
1093 + 'is_integrated' => defined('WPPAYFORM_VERSION'),
1094 + 'description' => __('All-in-one payment gateway designed for WordPress', 'fluent-support'),
1095 + 'doc_url' => 'https://paymattic.com/docs/how-to-integrate-fluent-support-with-paymattic-in-wordpress/',
1096 + ],
1097 + 'learn-dash' => [
1098 + 'title' => __('LearnDash', 'fluent-support'),
1099 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-dash.png',
1100 + 'is_integrated' => defined('LEARNDASH_VERSION'),
1101 + 'description' => __('The leading course platform built for WordPress', 'fluent-support'),
1102 + 'doc_url' => 'https://docs.fluentsupport.com/learndash-integration',
1103 + ],
1104 + 'learn-press' => [
1105 + 'title' => __('LearnPress', 'fluent-support'),
1106 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
1107 + 'is_integrated' => defined('LP_PLUGIN_FILE'),
1108 + 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
1109 + 'doc_url' => 'https://docs.fluentsupport.com/learnpress-integration',
1110 + ],
1111 + 'google-drive' => [
1112 + 'title' => __('Google Drive', 'fluent-support'),
1113 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
1114 + 'is_integrated' => self::getFSIntegrationStatus('google_drive_settings'),
1115 + 'description' => __('A cloud storage service by Google for storing, syncing, and sharing files.', 'fluent-support'),
1116 + 'doc_url' => 'https://docs.fluentsupport.com/google-drive-integration'
1117 + ],
1118 + 'dropbox' => [
1119 + 'title' => __('Dropbox', 'fluent-support'),
1120 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
1121 + 'is_integrated' => self::getFSIntegrationStatus('dropbox_settings'),
1122 + 'description' => __('A cloud-based file storage and sharing service that allows users to store files online and sync them across devices.', 'fluent-support'),
1123 + 'doc_url' => 'https://docs.fluentsupport.com/dropbox-integration',
1124 + ],
1125 + 'member-press' => [
1126 + 'title' => __('MemberPress', 'fluent-support'),
1127 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
1128 + 'is_integrated' => class_exists('MeprUtils'),
1129 + 'description' => __('A WordPress plugin that enables the creation and management of membership sites, including content access control and subscription billing.', 'fluent-support'),
1130 + 'doc_url' => 'https://docs.fluentsupport.com/memberpress-integration'
1131 + ],
1132 + 'google-recaptcha' => [
1133 + 'title' => __('Google reCAPTCHA', 'fluent-support'),
1134 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
1135 + 'is_integrated' => self::getFSIntegrationStatus('recaptcha_setting'),
1136 + 'description' => __('A security service by Google designed to protect websites from bots and abuse by using challenges to distinguish between human and automated access.', 'fluent-support'),
1137 + 'doc_url' => 'https://docs.fluentsupport.com/google-recaptcha-integration',
1138 + ],
1139 + 'fluent-boards' => [
1140 + 'title' => __('FluentBoards', 'fluent-support'),
1141 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
1142 + 'is_integrated' => defined('FLUENT_BOARDS'),
1143 + 'description' => __('A project management tool designed to streamline workflows and collaboration through customizable, kanban-style boards.', 'fluent-support'),
1144 + 'doc_url' => 'https://docs.fluentsupport.com/fluentboards-integrations#fluentboards-integration',
1145 + ],
1146 + 'fluent-booking' => [
1147 + 'title' => __('FluentBooking', 'fluent-support'),
1148 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-booking.svg',
1149 + 'is_integrated' => defined('FLUENT_BOOKING_VERSION'),
1150 + 'description' => __('Appointment and booking management plugin for WordPress', 'fluent-support'),
1151 + 'doc_url' => 'https://docs.fluentsupport.com/fluentbooking-integration',
1152 + ],
1153 + ];
1154 +
1155 + return $connections;
1156 + }
1157 +
1158 + public static function getGlobalSettingsMenu()
1159 + {
1160 + $menu = [
1161 + [
1162 + 'title' => __('Global Settings', 'fluent-support'),
1163 + 'route_name' => 'global_settings',
1164 + 'icon' => 'settings',
1165 + ],
1166 + [
1167 + 'title' => __('Ticket Tags', 'fluent-support'),
1168 + 'route_name' => 'tags',
1169 + 'icon' => 'ticketTag',
1170 + ],
1171 + [
1172 + 'title' => __('Ticket Form Config', 'fluent-support'),
1173 + 'route_name' => 'ticket-form-config',
1174 + 'icon' => 'formConfig',
1175 + ],
1176 + [
1177 + 'title' => __('Custom Fields', 'fluent-support'),
1178 + 'route_name' => 'custom_fields',
1179 + 'icon' => 'customFields',
1180 + ],
1181 + [
1182 + 'title' => __('Products', 'fluent-support'),
1183 + 'route_name' => 'products',
1184 + 'icon' => 'products',
1185 + ],
1186 + [
1187 + 'title' => __('Support Staff', 'fluent-support'),
1188 + 'route_name' => 'support-staffs',
1189 + 'icon' => 'supportStaffs',
1190 + ],
1191 + [
1192 + 'title' => __('FluentCRM Integration', 'fluent-support'),
1193 + 'route_name' => 'fluentcrm_integration',
1194 + 'icon' => 'crmIntegration',
1195 + ],
1196 + [
1197 + 'title' => __('Incoming Webhook', 'fluent-support'),
1198 + 'route_name' => 'incoming-webhook',
1199 + 'icon' => 'incomingWebhook',
1200 + ],
1201 + [
1202 + 'title' => __('Notification Integrations', 'fluent-support'),
1203 + 'route_name' => 'integration',
1204 + 'icon' => 'notification',
1205 + ],
1206 + [
1207 + 'title' => __('File Upload Integrations', 'fluent-support'),
1208 + 'route_name' => 'upload_integration',
1209 + 'icon' => 'fileUpload',
1210 + ],
1211 + [
1212 + 'title' => __('Auto Close Settings', 'fluent-support'),
1213 + 'route_name' => 'auto_close',
1214 + 'icon' => 'autoClose',
1215 + ],
1216 + [
1217 + 'title' => __('Ticket Importer', 'fluent-support'),
1218 + 'route_name' => 'ticket_importer',
1219 + 'icon' => 'importer',
1220 + ],
1221 + [
1222 + 'title' => __('Recaptcha', 'fluent-support'),
1223 + 'route_name' => 'reCaptcha',
1224 + 'icon' => 'reCaptcha',
1225 + ],
1226 + [
1227 + 'title' => __('Integration Statuses', 'fluent-support'),
1228 + 'route_name' => 'integration_statuses',
1229 + 'icon' => 'status',
1230 + ],
1231 + [
1232 + 'title' => __('AI Integration', 'fluent-support'),
1233 + 'icon' => 'aiIntegration',
1234 + 'children' => [
1235 + [
1236 + 'title' => __('AI Model Setup', 'fluent-support'),
1237 + 'route_name' => 'ai_integration',
1238 + ],
1239 + [
1240 + 'title' => __('MCP for AI Agents', 'fluent-support'),
1241 + 'route_name' => 'mcp_settings',
1242 + ],
1243 + ],
1244 + ],
1245 + ];
1246 +
1247 + if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
1248 + $menu[] = [
1249 + 'title' => __('License Management', 'fluent-support'),
1250 + 'route_name' => 'license',
1251 + 'icon' => 'license',
1252 + ];
1253 + }
1254 +
1255 + return apply_filters('fluent_support/settings_menu_items', $menu);
1256 + }
1257 +
1258 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1259 + public static function getFSIntegrationStatus($connection_name)
1260 + {
1261 + $integrationMap = [
1262 + 'slack_settings' => 'slack_settings',
1263 + 'discord_settings' => 'discord_settings',
1264 + 'twilio_settings' => 'twilio_settings',
1265 + 'telegram_settings' => 'telegram_settings',
1266 + 'google_drive_settings' => 'google_drive_settings',
1267 + 'dropbox_settings' => 'dropbox_settings',
1268 + 'recaptcha_setting' => '_fs_recaptcha_settings'
1269 + ];
1270 +
1271 + if (array_key_exists($connection_name, $integrationMap)) {
1272 + if ($connection_name == 'google_drive_settings' || $connection_name == 'dropbox_settings') {
1273 + return self::checkUploadDriverStatus($connection_name);
1274 + } elseif ($connection_name == 'recaptcha_setting') {
1275 + return self::checkRecaptchaStatus();
1276 + } else {
1277 + return self::checkNotificationIntegrationStatus($integrationMap[$connection_name]);
1278 + }
1279 + }
1280 +
1281 + return false;
1282 + }
1283 +
1284 + private static function checkNotificationIntegrationStatus($settingName)
1285 + {
1286 + $settings = self::getIntegrationOption($settingName, null);
1287 + if ($settings) {
1288 + $status = Arr::get($settings, 'status', false);
1289 + return $status ? true : false;
1290 + }
1291 + return false;
1292 + }
1293 +
1294 + private static function checkUploadDriverStatus($settingName)
1295 + {
1296 + $settings = self::getIntegrationOption($settingName, null);
1297 + if ($settings) {
1298 + $enabled = Arr::get($settings, 'status', false);
1299 + return $enabled ? true : false;
1300 + }
1301 + return false;
1302 + }
1303 +
1304 + private static function checkRecaptchaStatus()
1305 + {
1306 + $reCaptchaSettingsData = Meta::where('object_type', '_fs_recaptcha_settings')->first();
1307 +
1308 + if ($reCaptchaSettingsData) {
1309 + $settings = static::safeUnserialize($reCaptchaSettingsData->value);
1310 + $status = Arr::get($settings, 'is_enabled', false);
1311 + return $status == 'true' ? true : false;
1312 + }
1313 + return false;
1314 + }
1315 +
1316 + public static function getAIActivities($data)
1317 + {
1318 + $page = isset($data['page']) ? intval($data['page']) : 1;
1319 + $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1320 +
1321 + $activitiesQuery = AIActivityLogs::with([
1322 + 'person' => function ($query) {
1323 + $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1324 + },
1325 + 'ticket' => function ($query) {
1326 + $query->select(['id', 'title']);
1327 + }
1328 + ])->latest('id');
1329 +
1330 + $from = sanitize_text_field( Arr::get( $data, 'from', '' ) );
1331 + $to = sanitize_text_field( Arr::get( $data, 'to', '') );
1332 +
1333 + if ( $from != $to ) {
1334 + $from = $from . ' ' . '00:00:00';
1335 + $to = $to . ' ' . '23:59:59';
1336 + }
1337 +
1338 + if ( ( !empty($from) && !empty($to) ) && $from == $to ) {
1339 + $activitiesQuery->whereDate('created_at', '=', $from);
1340 + } elseif (!empty($from) && !empty($to)) {
1341 + $activitiesQuery->whereBetween('created_at', [ $from, $to ]);
1342 + }
1343 +
1344 + $agentId = intval( Arr::get($data, 'filters.agent_id') );
1345 +
1346 + if ($agentId) {
1347 + $activitiesQuery->where('agent_id', $agentId);
1348 + }
1349 +
1350 + $activities = $activitiesQuery->paginate($perPage, ['*'], 'page', $page);
1351 +
1352 + $settings = static::getSettings();
1353 +
1354 + return [
1355 + 'data' => $activities->items(),
1356 + 'total' => $activities->total(),
1357 + 'per_page' => $activities->perPage(),
1358 + 'current_page' => $activities->currentPage(),
1359 + 'last_page' => $activities->lastPage(),
1360 + 'settings' => $settings['ai_activity_settings']
1361 + ];
1362 + }
1363 +
1364 + public static function updateAISettings($settings)
1365 + {
1366 + $defaults = [
1367 + 'delete_days' => 14,
1368 + 'disable_logs' => 'no'
1369 + ];
1370 +
1371 + $settings = wp_parse_args($settings, $defaults);
1372 + $settings['delete_days'] = (int)$settings['delete_days'];
1373 +
1374 + Helper::updateOption('_ai_activity_settings', $settings);
1375 +
1376 + return [
1377 + 'message' => __('AI Activity settings have been updated', 'fluent-support')
1378 + ];
1379 + }
1380 +
1381 + public static function getSettings()
1382 + {
1383 + $settings = Helper::getOption('_ai_activity_settings', []);
1384 +
1385 + $defaults = [
1386 + 'delete_days' => 14,
1387 + 'disable_logs' => 'no'
1388 + ];
1389 +
1390 + $settings = wp_parse_args($settings, $defaults);
1391 +
1392 + if (! $settings ) throw new \Exception(esc_html__('No activity settings found', 'fluent-support'));
1393 +
1394 + return [
1395 + 'ai_activity_settings' => $settings
1396 + ];
1397 + }
1398 +
1399 + /**
1400 + * Check if activity logs are disabled for a given option key
1401 + * @param string $optionKey The option key to check
1402 + * @return bool
1403 + */
1404 + public static function areLogsDisabled($optionKey)
1405 + {
1406 + if (empty($optionKey)) {
1407 + return false;
1408 + }
1409 +
1410 + $settings = Helper::getOption($optionKey, []);
1411 + $defaults = [
1412 + 'disable_logs' => 'no'
1413 + ];
1414 + $settings = wp_parse_args($settings, $defaults);
1415 + return isset($settings['disable_logs']) && $settings['disable_logs'] === 'yes';
1416 + }
1417 +
1418 + public static function getIp($anonymize = false)
1419 + {
1420 + static $ipAddress;
1421 +
1422 + if ($ipAddress) {
1423 + return $ipAddress;
1424 + }
1425 +
1426 + if (empty($_SERVER['REMOTE_ADDR'])) {
1427 + // It's a local cli request
1428 + return '127.0.0.1';
1429 + }
1430 +
1431 + $ipAddress = '';
1432 + $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1433 +
1434 + if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1435 + //If it's a valid Cloudflare request
1436 + if (self::isCfIp($remoteAddr)) {
1437 + //Use the CF-Connecting-IP header.
1438 + $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1439 + } else {
1440 + //If it isn't valid, then use REMOTE_ADDR.
1441 + $ipAddress = $remoteAddr;
1442 + }
1443 + } else if ($remoteAddr == '127.0.0.1') {
1444 + // most probably it's local reverse proxy
1445 + if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1446 + $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1447 + } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1448 + $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
1449 + $splitResult = preg_split('/,/', $forwardedFor);
1450 + $firstIp = is_array($splitResult) ? current($splitResult) : '';
1451 + $ipAddress = (string)rest_is_ip_address(trim((string)$firstIp));
1452 + }
1453 + }
1454 +
1455 + if (!$ipAddress) {
1456 + $ipAddress = $remoteAddr;
1457 + }
1458 +
1459 + if ($ipAddress) {
1460 + $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1461 + }
1462 +
1463 + $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1464 +
1465 + if ($anonymize) {
1466 + return wp_privacy_anonymize_ip($ipAddress);
1467 + }
1468 +
1469 + $ipAddress = sanitize_text_field(wp_unslash($ipAddress));
1470 +
1471 + return $ipAddress;
1472 + }
1473 +
1474 + /**
1475 + * Atomically increments the counter for $rateLimitKey and reports whether the
1476 + * limit is now exceeded. On sites with a persistent external object cache
1477 + * (Redis/Memcached), wp_cache_incr() is a real atomic increment, so concurrent
1478 + * requests can't race past the limit. Without one, this falls back to a plain
1479 + * transient read/write (same best-effort behavior as the rest of this codebase's
1480 + * rate limiters, e.g. AuthController::incrementLoginAttempts).
1481 + *
1482 + * Note the counter is incremented before it is compared, so the returned value
1483 + * already accounts for the current request.
1484 + */
1485 + public static function hitRateLimit($rateLimitKey, $limit, $window = null)
1486 + {
1487 + $window = $window ?: 15 * MINUTE_IN_SECONDS;
1488 +
1489 + if (wp_using_ext_object_cache()) {
1490 + $group = 'fs_rate_limit';
1491 + if (false === wp_cache_get($rateLimitKey, $group)) {
1492 + wp_cache_add($rateLimitKey, 0, $group, $window);
1493 + }
1494 + $attempts = wp_cache_incr($rateLimitKey, 1, $group);
1495 +
1496 + // Fail closed: if the cache backend couldn't increment (evicted key, hiccup),
1497 + // treat the request as rate-limited rather than silently letting it through.
1498 + if ($attempts === false) {
1499 + return true;
1500 + }
1501 +
1502 + return $attempts > $limit;
1503 + }
1504 +
1505 + $now = time();
1506 + $record = get_transient($rateLimitKey);
1507 +
1508 + // A record without a live deadline means the window is over (or the record predates
1509 + // this format), so the count starts again. Anything else keeps the deadline it was
1510 + // created with.
1511 + //
1512 + // The `<=` must not be loosened to `<`: it is what guarantees the TTL below is at
1513 + // least 1. A request landing exactly on the deadline would otherwise compute a TTL
1514 + // of 0, and set_transient() reads 0 as "never expires" — wedging this limiter shut
1515 + // permanently.
1516 + if (!is_array($record) || empty($record['expires']) || $record['expires'] <= $now) {
1517 + $record = ['count' => 0, 'expires' => $now + $window];
1518 + }
1519 +
1520 + $record['count']++;
1521 +
1522 + // The TTL is the time left until the original deadline, never the full window:
1523 + // set_transient() rewrites expiry on every call, so passing $window here would let
1524 + // rejected requests push the deadline forward and keep a tripped limiter tripped
1525 + // for as long as traffic kept arriving.
1526 + set_transient($rateLimitKey, $record, $record['expires'] - $now);
1527 +
1528 + return $record['count'] > $limit;
1529 + }
1530 +
1531 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1532 + public static function isCfIp($ip = '')
1533 + {
1534 + if (!$ip) {
1535 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1536 + }
1537 + $cloudflareIPRanges = array(
1538 + '103.21.244.0/22',
1539 + '103.22.200.0/22',
1540 + '103.31.4.0/22',
1541 + '104.16.0.0/13',
1542 + '104.24.0.0/14',
1543 + '108.162.192.0/18',
1544 + '131.0.72.0/22',
1545 + '141.101.64.0/18',
1546 + '162.158.0.0/15',
1547 + '172.64.0.0/13',
1548 + '173.245.48.0/20',
1549 + '188.114.96.0/20',
1550 + '190.93.240.0/20',
1551 + '197.234.240.0/22',
1552 + '198.41.128.0/17'
1553 + );
1554 + $validCFRequest = false;
1555 + //Make sure that the request came via Cloudflare.
1556 + foreach ($cloudflareIPRanges as $range) {
1557 + //Use the ip_in_range function from Joomla.
1558 + if (self::ipInRange($ip, $range)) {
1559 + //IP is valid. Belongs to Cloudflare.
1560 + return true;
1561 + }
1562 + }
1563 +
1564 + return false;
1565 + }
1566 +
1567 + private static function ipInRange($ip, $range)
1568 + {
1569 + if (!$ip || !$range || !is_string($ip) || !is_string($range)) {
1570 + return false;
1571 + }
1572 +
1573 + if (strpos($range, '/') !== false) {
1574 + // $range is in IP/NETMASK format
1575 + list($range, $netmask) = explode('/', $range, 2);
1576 + if (strpos($netmask, '.') !== false) {
1577 + // $netmask is a 255.255.0.0 format
1578 + $netmask = str_replace('*', '0', $netmask);
1579 + $netmask_dec = ip2long($netmask);
1580 + return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
1581 + } else {
1582 + // $netmask is a CIDR size block
1583 + // fix the range argument
1584 + $x = explode('.', $range);
1585 + while (count($x) < 4) $x[] = '0';
1586 + list($a, $b, $c, $d) = $x;
1587 + $range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c, empty($d) ? '0' : $d);
1588 + $range_dec = ip2long($range);
1589 + $ip_dec = ip2long($ip);
1590 +
1591 + # Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
1592 + #$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
1593 +
1594 + # Strategy 2 - Use math to create it
1595 + $wildcard_dec = pow(2, (32 - $netmask)) - 1;
1596 + $netmask_dec = ~$wildcard_dec;
1597 +
1598 + return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
1599 + }
1600 + } else {
1601 + // range might be 255.255.*.* or 1.2.3.0-1.2.3.255
1602 + if (strpos($range, '*') !== false) { // a.b.*.* format
1603 + // Just convert to A-B format by setting * to 0 for A and 255 for B
1604 + $lower = str_replace('*', '0', $range);
1605 + $upper = str_replace('*', '255', $range);
1606 + $range = "$lower-$upper";
1607 + }
1608 +
1609 + if (strpos($range, '-') !== false) { // A-B format
1610 + list($lower, $upper) = explode('-', $range, 2);
1611 + $lower_dec = (float)sprintf("%u", ip2long($lower));
1612 + $upper_dec = (float)sprintf("%u", ip2long($upper));
1613 + $ip_dec = (float)sprintf("%u", ip2long($ip));
1614 + return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
1615 + }
1616 + return false;
1617 + }
1618 + }
1619 +
1620 + public static function loadView($template, $data)
1621 + {
1622 + extract($data, EXTR_OVERWRITE);
1623 +
1624 + $template = sanitize_file_name($template);
1625 +
1626 + $template = str_replace('.', DIRECTORY_SEPARATOR, $template);
1627 +
1628 + ob_start();
1629 + include FLUENT_SUPPORT_PLUGIN_PATH . 'app/Views/emails/' . $template . '.php';
1630 + return ob_get_clean();
1631 + }
1632 +
1633 + public static function isProductRequired()
1634 + {
1635 + $settings = Helper::getOption('_ticket_form_settings', []);
1636 + return Arr::get($settings, 'product_required_field') === 'yes';
1637 + }
1638 +
1639 + /**
1640 + * Build a fluentsupport.com upgrade/pricing link tagged with UTM params
1641 + * per the standard "Upgrade to Pro" link spec:
1642 + * utm_source = fluent-support (fixed vocabulary, never the wp.org slug)
1643 + * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
1644 + * utm_campaign= upgrade_pro (override via $args['campaign'])
1645 + * utm_content = the exact placement, e.g. feature_lock_dropbox, upgrade_page
1646 + * utm_term = plugin version that generated the link
1647 + *
1648 + * This is the single source of truth for upgrade URLs. The admin SPA does
1649 + * not rebuild these client-side; it re-points utm_content on the URL this
1650 + * method localizes into appVars (see the $upgradeUrl mixin in start.js).
1651 + *
1652 + * @param string $content The utm_content placement.
1653 + * @param array $args Optional: 'campaign', 'base_url', plus any utm_* override.
1654 + * @return string
1655 + */
1656 + public static function getUpgradeUrl($content = 'upgrade_page', $args = [])
1657 + {
1658 + $args = wp_parse_args($args, [
1659 + 'campaign' => 'upgrade_pro',
1660 + 'base_url' => apply_filters('fluent_support/pro_upgrade_base_url', 'https://fluentsupport.com/pricing'),
1661 + ]);
1662 +
1663 + $params = [
1664 + 'utm_source' => 'fluent-support',
1665 + // Same constant Menu.php uses for appVars.has_pro — keep the two in sync.
1666 + 'utm_medium' => defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') ? 'pro_plugin' : 'free_plugin',
1667 + 'utm_campaign' => $args['campaign'],
1668 + 'utm_content' => $content,
1669 + 'utm_term' => FLUENT_SUPPORT_VERSION,
1670 + ];
1671 +
1672 + // Drop any blank params (e.g. a missing version) so they never hit the URL.
1673 + $params = array_filter($params, function ($value) {
1674 + return $value !== '' && $value !== null;
1675 + });
1676 +
1677 + return add_query_arg($params, $args['base_url']);
1678 + }
1679 +
1680 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1681 + public static function getBusinessBox()
1682 + {
1683 + $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
1684 + ->where('box_type', 'email')
1685 + ->get();
1686 + return $businessEmailBoxes;
1687 + }
1688 +
1689 + public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1690 + {
1691 + // Fetch content based on the content type
1692 + $content = self::getContentByType($ticketId, $contentType , $replyId);
1693 + if (empty($content)) {
1694 + return;
1695 + }
1696 +
1697 + // Extract image URLs from the content
1698 + $imageUrls = self::extractImageUrls($content);
1699 + if (empty($imageUrls)) {
1700 + return;
1701 + }
1702 +
1703 + // Move images to the upload directory and update content
1704 + self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1705 + }
1706 +
1707 + /**
1708 + * Fetch content based on the content type.
1709 + */
1710 + private static function getContentByType($ticketId, $contentType, $replyId)
1711 + {
1712 + if ($contentType == 'ticket-create') {
1713 + $ticket = Ticket::find($ticketId);
1714 + return $ticket ? $ticket->content : null;
1715 + }
1716 +
1717 + $conversation = Conversation::find($replyId);
1718 + return $conversation ? $conversation->content : null;
1719 + }
1720 +
1721 + /**
1722 + * Extract image URLs from the content.
1723 + */
1724 + private static function extractImageUrls($content)
1725 + {
1726 + preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1727 + return $matches[2] ?? [];
1728 + }
1729 +
1730 + /**
1731 + * Move images to the upload directory and update content.
1732 + */
1733 + private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1734 + {
1735 + // Get the current site's upload directory
1736 + $uploadDirInfo = wp_upload_dir();
1737 + $uploadsDir = $uploadDirInfo['basedir'];
1738 + $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1739 + $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
1740 +
1741 + foreach ($imageUrls as $imageUrl) {
1742 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1743 + if ($fileHash && isset($signedAttachments[$fileHash]) && ($referenceUrl = self::finalizeSignedTempImage($signedAttachments[$fileHash], $ticketId, $contentType, $replyId, $personId))) {
1744 + $content = str_replace($imageUrl, $referenceUrl, $content);
1745 + continue;
1746 + }
1747 +
1748 + // Build the absolute path for the temporary file
1749 + $imageRelativePath = $tempDir . basename($imageUrl);
1750 + $absolutePath = $imageRelativePath;
1751 +
1752 + // Move the file to the ticket-specific folder
1753 + $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1754 +
1755 + // Check if the move was successful
1756 + if (empty($newFileInfo['file_path'])) {
1757 + continue; // Skip if the file couldn't be copied
1758 + }
1759 +
1760 + // Ensure the new URL is correctly constructed
1761 + $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1762 +
1763 + // Replace the old URL with the new one in the content
1764 + $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1765 + }
1766 +
1767 + // Save the updated content
1768 + self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1769 + }
1770 +
1771 + private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
1772 + {
1773 + $fileHashes = [];
1774 + foreach ($imageUrls as $imageUrl) {
1775 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1776 + if ($fileHash) {
1777 + $fileHashes[] = $fileHash;
1778 + }
1779 + }
1780 +
1781 + $fileHashes = array_values(array_unique($fileHashes));
1782 + if (!$fileHashes) {
1783 + return [];
1784 + }
1785 +
1786 + $attachments = Attachment::whereIn('file_hash', $fileHashes)
1787 + ->where(function ($query) use ($ticketId, $personId) {
1788 + $query->where('ticket_id', $ticketId);
1789 +
1790 + if ($personId) {
1791 + $query->orWhere(function ($query) use ($personId) {
1792 + $query->whereNull('ticket_id')
1793 + ->where('person_id', $personId);
1794 + });
1795 + }
1796 + })
1797 + ->get();
1798 + $mappedAttachments = [];
1799 + foreach ($attachments as $attachment) {
1800 + $mappedAttachments[$attachment->file_hash] = $attachment;
1801 + }
1802 +
1803 + return $mappedAttachments;
1804 + }
1805 +
1806 + private static function finalizeSignedTempImage($attachment, $ticketId, $contentType, $replyId, $personId)
1807 + {
1808 + if (!$attachment || $attachment->driver !== 'local') {
1809 + return false;
1810 + }
1811 +
1812 + if ($attachment->ticket_id && intval($attachment->ticket_id) !== intval($ticketId)) {
1813 + return false;
1814 + }
1815 +
1816 + if (!$attachment->ticket_id && $personId && intval($attachment->person_id) !== intval($personId)) {
1817 + return false;
1818 + }
1819 +
1820 + if ($attachment->conversation_id && $replyId && intval($attachment->conversation_id) !== intval($replyId)) {
1821 + return false;
1822 + }
1823 +
1824 + if ($attachment->conversation_id && $contentType === 'ticket-create') {
1825 + return false;
1826 + }
1827 +
1828 + if ($attachment->status !== 'in-active') {
1829 + return $attachment->ticket_id ? self::getAttachmentReferenceUrl($attachment) : false;
1830 + }
1831 +
1832 + if (!$attachment->file_path || !file_exists($attachment->file_path)) {
1833 + return false;
1834 + }
1835 +
1836 + $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticketId);
1837 + if (empty($newFileInfo['file_path'])) {
1838 + return false;
1839 + }
1840 +
1841 + $attachment->file_path = $newFileInfo['file_path'];
1842 + $attachment->full_url = $newFileInfo['url'];
1843 + $attachment->ticket_id = $attachment->ticket_id ?: $ticketId;
1844 + $attachment->status = 'inline';
1845 +
1846 + if ($contentType !== 'ticket-create' && $replyId) {
1847 + $attachment->conversation_id = $replyId;
1848 + }
1849 +
1850 + $attachment->save();
1851 +
1852 + return self::getAttachmentReferenceUrl($attachment);
1853 + }
1854 +
1855 + private static function extractAttachmentHashFromUrl($imageUrl)
1856 + {
1857 + $decodedUrl = html_entity_decode($imageUrl, ENT_QUOTES, 'UTF-8');
1858 + $query = wp_parse_url($decodedUrl, PHP_URL_QUERY);
1859 +
1860 + if (!$query) {
1861 + return '';
1862 + }
1863 +
1864 + parse_str($query, $params);
1865 +
1866 + return !empty($params['fst_file']) ? sanitize_text_field($params['fst_file']) : '';
1867 + }
1868 +
1869 + private static function getAttachmentReferenceUrl($attachment)
1870 + {
1871 + return add_query_arg([
1872 + 'fst_file' => $attachment->file_hash
1873 + ], site_url('/index.php'));
1874 + }
1875 +
1876 + public static function refreshSignedAttachmentUrls($content, $ticketId = null)
1877 + {
1878 + $contents = self::refreshSignedAttachmentUrlsInContents([$content], $ticketId);
1879 +
1880 + return $contents[0];
1881 + }
1882 +
1883 + public static function refreshSignedAttachmentUrlsInContents($contents, $ticketId = null)
1884 + {
1885 + $fileHashes = [];
1886 +
1887 + foreach ($contents as $content) {
1888 + foreach (self::extractImageUrls($content) as $imageUrl) {
1889 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1890 + if ($fileHash) {
1891 + $fileHashes[] = $fileHash;
1892 + }
1893 + }
1894 + }
1895 +
1896 + $fileHashes = array_values(array_unique($fileHashes));
1897 + if (!$fileHashes) {
1898 + return $contents;
1899 + }
1900 +
1901 + $attachmentsQuery = Attachment::whereIn('file_hash', $fileHashes);
1902 + if ($ticketId) {
1903 + $attachmentsQuery->where('ticket_id', $ticketId);
1904 + }
1905 +
1906 + $attachments = $attachmentsQuery->get();
1907 + $attachmentsByHash = [];
1908 +
1909 + foreach ($attachments as $attachment) {
1910 + $attachmentsByHash[$attachment->file_hash] = $attachment;
1911 + }
1912 +
1913 + foreach ($contents as $key => $content) {
1914 + foreach (self::extractImageUrls($content) as $imageUrl) {
1915 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1916 + if (!$fileHash || empty($attachmentsByHash[$fileHash])) {
1917 + continue;
1918 + }
1919 +
1920 + $content = str_replace($imageUrl, $attachmentsByHash[$fileHash]->secureUrl, $content);
1921 + }
1922 +
1923 + $contents[$key] = $content;
1924 + }
1925 +
1926 + return $contents;
1927 + }
1928 +
1929 + /**
1930 + * Save the updated content based on the content type.
1931 + */
1932 + private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1933 + {
1934 + if ($contentType == 'ticket-create') {
1935 + $ticket = Ticket::find($ticketId);
1936 + if ($ticket) {
1937 + $ticket->content = $content;
1938 + $ticket->save();
1939 + }
1940 + } else {
1941 + $conversation = Conversation::find($replyId);
1942 + if ($conversation) {
1943 + $conversation->content = $content;
1944 + $conversation->save();
1945 + }
1946 + }
1947 + }
1948 +
1949 + /**
1950 + * Throw a ValidationException with a safe error message.
1951 + * In debug mode the real exception message is used; in production a generic message is returned.
1952 + * Throwing instead of returning ensures the error bypasses the framework's HTTP exception
1953 + * wrapper (which would prepend a status-code prefix to the message).
1954 + *
1955 + * @param \Throwable $e The original exception.
1956 + * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1957 + */
1958 + public static function getSafeErrorMessage($e)
1959 + {
1960 + $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1961 +
1962 + // ValidationException carries the message through the framework's JSON error
1963 + // handler — it is never echoed directly. $e is the chained previous exception,
1964 + // not output. phpcs:disable covers the multi-line constructor call.
1965 + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1966 + throw new \FluentSupport\Framework\Validator\ValidationException(
1967 + '', 422, ($e instanceof \Exception ? $e : null), ['message' => $message]
1968 + );
1969 + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1970 + }
1971 +
1972 + /**
1973 + * Safely unserialize data.
1974 + *
1975 + * @param string $data The serialized data.
1976 + * @return mixed The unserialized data or the original data if not serialized.
1977 + */
1978 + public static function safeUnserialize($data)
1979 + {
1980 + if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1981 + return @unserialize(trim($data), ['allowed_classes' => false]);
1982 + }
1983 +
1984 + return $data;
525 1985 }
526 1986 }