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