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
← All changes | app/Services/Helper.php +1569 -32 1.5.4 → 2.4.5 View file →
@@ -3,12 +3,18 @@
3 3 namespace FluentSupport\App\Services;
4 4
5 5 use FluentSupport\App\App;
6 6 use FluentSupport\App\Models\Agent;
7 +use FluentSupport\App\Models\Attachment;
8 +use FluentSupport\App\Models\Ticket;
9 +use FluentSupport\App\Models\Conversation;
7 10 use FluentSupport\App\Models\Customer;
8 11 use FluentSupport\App\Models\MailBox;
9 12 use FluentSupport\App\Models\Meta;
13 +use FluentSupport\App\Models\AIActivityLogs;
10 14 use FluentSupport\App\Models\Person;
15 +use FluentSupport\App\Models\Product;
16 +use FluentSupport\App\Services\Includes\UploadService;
11 17 use FluentSupport\App\Services\EmailNotification\Settings;
12 18 use FluentSupport\Framework\Support\Arr;
13 19
14 20 class Helper
@@ -17,8 +23,14 @@
17 23 {
18 24 return App::getInstance($module);
19 25 }
20 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 + */
21 33 public static function getAgentByUserId($userId = null)
22 34 {
23 35 if ($userId === null) {
24 36 $userId = get_current_user_id();
@@ -25,12 +37,14 @@
25 37 }
26 38 if (!$userId) {
27 39 return false;
28 40 }
29 -
30 41 return Agent::where('user_id', $userId)->first();
31 42 }
32 43
44 + /**
45 + * @return mixed
46 + */
33 47 public static function customerTicketPriorities()
34 48 {
35 49 return apply_filters('fluent_support/customer_ticket_priorities', [
36 50 'normal' => __('Normal', 'fluent-support'),
@@ -38,8 +52,11 @@
38 52 'critical' => __('Critical', 'fluent-support')
39 53 ]);
40 54 }
41 55
56 + /**
57 + * @return mixed
58 + */
42 59 public static function adminTicketPriorities()
43 60 {
44 61 return apply_filters('fluent_support/admin_ticket_priorities', [
45 62 'normal' => __('Normal', 'fluent-support'),
@@ -47,8 +64,12 @@
47 64 'critical' => __('Critical', 'fluent-support')
48 65 ]);
49 66 }
50 67
68 +
69 + /**
70 + * @return mixed
71 + */
51 72 public static function ticketStatusGroups()
52 73 {
53 74 return apply_filters('fluent_support/ticket_status_groups', [
54 75 'open' => ['new', 'active'],
@@ -58,14 +79,30 @@
58 79 'all' => []
59 80 ]);
60 81 }
61 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 + */
62 99 public static function ticketStatuses()
63 100 {
64 101 return apply_filters('fluent_support/ticket_statuses', [
65 102 'new' => __('New', 'fluent-support'),
66 - 'active' => __('Active', 'fluent-support'),
67 - 'closed' => __('Closed', 'fluent-support'),
103 + 'active' => __('Active', 'fluent-support'),
104 + 'closed' => __('Closed', 'fluent-support'),
68 105 ]);
69 106 }
70 107
71 108 public static function getTkStatusesByGroupName($groupName)
@@ -113,16 +150,17 @@
113 150 public static function getFileUploadMessage()
114 151 {
115 152 $mimeHeadings = self::getAcceptedMimeHeadings();
116 153 $settings = (new Settings())->globalBusinessSettings();
117 - $maxFileSize = absint($settings['max_file_size']);
154 + $maxFileSize = floatval($settings['max_file_size']);
118 155
119 - return sprintf(__('Supported Types: %s and max file size: %dMB', 'fluent-support'), implode(', ', $mimeHeadings), $maxFileSize);
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);
120 158 }
121 159
122 160 public static function getMimeGroups()
123 161 {
124 - return [
162 + return apply_filters('fluent_support/mime_groups', [
125 163 'images' => [
126 164 'title' => __('Photos', 'fluent-support'),
127 165 'mimes' => [
128 166 'image/gif',
@@ -152,8 +190,10 @@
152 190 'application/vnd.ms-excel',
153 191 'application/vnd.msexcel',
154 192 'application/octet-stream',
155 193 'application/pdf',
194 + 'application/msword',
195 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
156 196 ]
157 197 ],
158 198 'zip' => [
159 199 'title' => __('Zip', 'fluent-support'),
@@ -167,11 +207,16 @@
167 207 'application/json',
168 208 'application/jsonml+json'
169 209 ]
170 210 ]
171 - ];
211 + ]);
172 212 }
173 213
214 + /**
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
217 + * @return mixed|string
218 + */
174 219 public static function getOption($key, $default = '')
175 220 {
176 221 $data = Meta::where('object_type', 'option')
177 222 ->where('key', $key)
@@ -177,9 +222,9 @@
177 222 ->where('key', $key)
178 223 ->first();
179 224
180 225 if ($data) {
181 - $value = maybe_unserialize($data->value);
226 + $value = static::safeUnserialize($data->value);
182 227 if ($value) {
183 228 return $value;
184 229 }
185 230 }
@@ -186,8 +231,15 @@
186 231
187 232 return $default;
188 233 }
189 234
235 + /**
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
240 + * @return mixed
241 + */
190 242 public static function updateOption($key, $value)
191 243 {
192 244 $data = Meta::where('object_type', 'option')
193 245 ->where('key', $key)
@@ -206,8 +258,21 @@
206 258 'value' => maybe_serialize($value)
207 259 ]);
208 260 }
209 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 + */
210 275 public static function getIntegrationOption($key, $default = '')
211 276 {
212 277 $data = Meta::where('object_type', 'integration_settings')
213 278 ->where('key', $key)
@@ -213,9 +278,9 @@
213 278 ->where('key', $key)
214 279 ->first();
215 280
216 281 if ($data) {
217 - $value = maybe_unserialize($data->value);
282 + $value = static::safeUnserialize($data->value);
218 283 if ($value) {
219 284 return $value;
220 285 }
221 286 }
@@ -222,8 +287,15 @@
222 287
223 288 return $default;
224 289 }
225 290
291 + /**
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
296 + * @return mixed
297 + */
226 298 public static function updateIntegrationOption($key, $value)
227 299 {
228 300 $data = Meta::where('object_type', 'integration_settings')
229 301 ->where('key', $key)
@@ -245,10 +317,10 @@
245 317
246 318 public static function getTicketViewUrl($ticket)
247 319 {
248 320 $baseUrl = self::getPortalBaseUrl();
249 -
250 - return $baseUrl . '/#/ticket/' . $ticket->id . '/view';
321 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
322 + return $baseUrl . '/#/ticket/' . $ticketNumber . '/view';
251 323 }
252 324
253 325 public static function getTicketViewSignedUrl($ticket)
254 326 {
@@ -256,18 +328,139 @@
256 328 return self::getTicketViewUrl($ticket);
257 329 }
258 330
259 331 $baseUrl = self::getPortalBaseUrl();
332 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
260 333
261 334 $baseUrl = add_query_arg([
262 335 'fs_view' => 'ticket',
263 336 'support_hash' => $ticket->hash,
264 - 'ticket_id' => $ticket->id
337 + 'ticket_id' => $ticketNumber,
265 338 ], $baseUrl);
266 339
267 - return $baseUrl . '#/ticket/' . $ticket->id . '/view';
340 + return $baseUrl . '#/ticket/' . $ticketNumber . '/view';
268 341 }
269 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 +
270 463 public static function isPublicSignedTicketEnabled()
271 464 {
272 465 $businessSettings = self::getBusinessSettings();
273 466
@@ -279,34 +472,150 @@
279 472 $baseUrl = self::getPortalAdminBaseUrl();
280 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
281 474 }
282 475
476 + /**
477 + * @return mixed
478 + */
283 479 public static function getPortalBaseUrl()
284 480 {
285 481 $businessSettings = self::getBusinessSettings();
286 - $baseUrl = get_permalink($businessSettings['portal_page_id']);
287 - $baseUrl = rtrim($baseUrl, '/\\');
288 - return apply_filters('fluent_support/portal_base_url', $baseUrl);
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, '/\\'));
289 503 }
290 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 +
291 522 public static function getPortalAdminBaseUrl()
292 523 {
293 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
294 525 }
295 526
296 - public static function getBusinessSettings()
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)
297 536 {
298 537 static $settings;
299 538
300 - if ($settings) {
301 - return $settings;
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
302 541 }
303 542
304 - $settings = (new Settings())->globalBusinessSettings();
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
545 + }
305 546
306 547 return $settings;
307 548 }
308 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 +
309 618 public static function getTicketMeta($ticketId, $key, $default = '')
310 619 {
311 620 $data = Meta::where('object_type', 'ticket_meta')
312 621 ->where('key', $key)
@@ -313,9 +622,9 @@
313 622 ->where('object_id', $ticketId)
314 623 ->first();
315 624
316 625 if ($data) {
317 - $value = maybe_unserialize($data->value);
626 + $value = static::safeUnserialize($data->value);
318 627 if ($value) {
319 628 return $value;
320 629 }
321 630 }
@@ -351,15 +660,15 @@
351 660 ->table('posts')
352 661 ->select(['ID', 'post_title'])
353 662 ->where('post_type', 'page')
354 663 ->where('post_status', 'publish')
355 - ->orderBy('ID', 'DESC')
664 + ->latest('ID')
356 665 ->get();
357 666 $formattedPages = [];
358 667 foreach ($pages as $page) {
359 668 $formattedPages[] = [
360 - 'id' => strval($page->ID),
361 - 'title' => $page->post_title
669 + 'id' => intval($page->ID),
670 + 'title' => $page->post_title ?: __('(no title)', 'fluent-support')
362 671 ];
363 672 }
364 673 return $formattedPages;
365 674 }
@@ -371,26 +680,54 @@
371 680 if ($mailbox) {
372 681 return $mailbox;
373 682 }
374 683
375 - return MailBox::orderBy('id', 'ASC')->first();
684 + return MailBox::oldest('id')->first();
376 685 }
377 686
378 687 public static function getCurrentAgent()
379 688 {
380 - return Agent::where('user_id', get_current_user_id())->first();
689 + if (get_current_user_id()) {
690 + return Agent::where('user_id', get_current_user_id())->first();
691 + }
381 692 }
382 693
383 694 public static function getCurrentCustomer()
384 695 {
385 - return Customer::where('user_id', get_current_user_id())->first();
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 + }
386 703 }
387 704
388 705 public static function getCurrentPerson()
389 706 {
390 - return Person::where('user_id', get_current_user_id())->first();
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;
391 713 }
392 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 +
393 730 public static function getFluentCRMTagConfig()
394 731 {
395 732 if (!defined('FLUENTCRM')) {
396 733 return [
@@ -395,8 +732,9 @@
395 732 if (!defined('FLUENTCRM')) {
396 733 return [
397 734 'can_add_tags' => false,
398 735 'tags' => [],
736 + 'lists' => [],
399 737 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
400 738 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
401 739 ];
402 740 }
@@ -404,21 +742,33 @@
404 742 $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
405 743
406 744 $canAddTags = apply_filters('fluent_support/can_user_add_tags_to_customer', $canAddTags);
407 745 $crmTags = [];
746 + $crmLists = [];
408 747 if ($canAddTags) {
409 - $crmTags = \FluentCrm\App\Models\Tag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
748 + $crmTags = \FluentCrm\App\Models\Tag::select(['id', 'title'])->oldest('title')->get();
749 + $crmLists = \FluentCrm\App\Models\Lists::select(['id', 'title'])->oldest('title')->get();
410 750 }
411 751
412 - return [
752 + $crmConfigs = [
413 753 'can_add_tags' => $canAddTags,
414 754 'tags' => $crmTags,
755 + 'lists' => $crmLists,
415 756 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
416 757 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
417 758 ];
418 759
760 + if (defined('FLUENTCRM')) {
761 + $crmConfigs['contacts'] = []; //(new \FluentCrm\App\Models\Subscriber)->get();
762 + }
763 +
764 + return $crmConfigs;
419 765 }
420 766
767 + /**
768 + * @param Customer $customer
769 + * @return array|false
770 + */
421 771 public static function getFluentCrmContactData($customer)
422 772 {
423 773 if (!defined('FLUENTCRM')) {
424 774 return false;
@@ -425,12 +775,13 @@
425 775 }
426 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
427 777 if ($contact) {
428 778 $tags = $contact->tags;
779 + $lists = $contact->lists;
429 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
430 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
431 782
432 - $contactData = [
783 + return [
433 784 'id' => $contact->id,
434 785 'first_name' => $contact->first_name,
435 786 'last_name' => $contact->last_name,
436 787 'full_name' => $contact->full_name,
@@ -435,15 +786,1201 @@
435 786 'last_name' => $contact->last_name,
436 787 'full_name' => $contact->full_name,
437 788 'name_mismatch' => $contact->full_name != $customer->full_name,
438 789 'tags' => $tags,
790 + 'lists' => $lists,
439 791 'status' => $contact->status,
440 792 'stats' => $contact->stats(),
441 793 'view_url' => $crmProfileUrl
442 794 ];
795 + }
443 796
444 - return $contactData;
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;
445 806 }
446 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 +
447 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;
448 1985 }
449 1986 }