PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
fluent-support / app / Services / Helper.php

Helper.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/Helper.php

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