PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 1.5.6 All 67 releases
fluent-support / app / Services / Helper.php

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

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