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 +1576 -31 1.5.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, []);
@@ -104,16 +150,17 @@
104 150 public static function getFileUploadMessage()
105 151 {
106 152 $mimeHeadings = self::getAcceptedMimeHeadings();
107 153 $settings = (new Settings())->globalBusinessSettings();
108 - $maxFileSize = absint($settings['max_file_size']);
154 + $maxFileSize = floatval($settings['max_file_size']);
109 155
110 - 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);
111 158 }
112 159
113 160 public static function getMimeGroups()
114 161 {
115 - return [
162 + return apply_filters('fluent_support/mime_groups', [
116 163 'images' => [
117 164 'title' => __('Photos', 'fluent-support'),
118 165 'mimes' => [
119 166 'image/gif',
@@ -143,8 +190,10 @@
143 190 'application/vnd.ms-excel',
144 191 'application/vnd.msexcel',
145 192 'application/octet-stream',
146 193 'application/pdf',
194 + 'application/msword',
195 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
147 196 ]
148 197 ],
149 198 'zip' => [
150 199 'title' => __('Zip', 'fluent-support'),
@@ -158,11 +207,16 @@
158 207 'application/json',
159 208 'application/jsonml+json'
160 209 ]
161 210 ]
162 - ];
211 + ]);
163 212 }
164 213
214 + /**
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
217 + * @return mixed|string
218 + */
165 219 public static function getOption($key, $default = '')
166 220 {
167 221 $data = Meta::where('object_type', 'option')
168 222 ->where('key', $key)
@@ -168,9 +222,9 @@
168 222 ->where('key', $key)
169 223 ->first();
170 224
171 225 if ($data) {
172 - $value = maybe_unserialize($data->value);
226 + $value = static::safeUnserialize($data->value);
173 227 if ($value) {
174 228 return $value;
175 229 }
176 230 }
@@ -177,8 +231,15 @@
177 231
178 232 return $default;
179 233 }
180 234
235 + /**
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
240 + * @return mixed
241 + */
181 242 public static function updateOption($key, $value)
182 243 {
183 244 $data = Meta::where('object_type', 'option')
184 245 ->where('key', $key)
@@ -197,8 +258,21 @@
197 258 'value' => maybe_serialize($value)
198 259 ]);
199 260 }
200 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 + */
201 275 public static function getIntegrationOption($key, $default = '')
202 276 {
203 277 $data = Meta::where('object_type', 'integration_settings')
204 278 ->where('key', $key)
@@ -204,9 +278,9 @@
204 278 ->where('key', $key)
205 279 ->first();
206 280
207 281 if ($data) {
208 - $value = maybe_unserialize($data->value);
282 + $value = static::safeUnserialize($data->value);
209 283 if ($value) {
210 284 return $value;
211 285 }
212 286 }
@@ -213,8 +287,15 @@
213 287
214 288 return $default;
215 289 }
216 290
291 + /**
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
296 + * @return mixed
297 + */
217 298 public static function updateIntegrationOption($key, $value)
218 299 {
219 300 $data = Meta::where('object_type', 'integration_settings')
220 301 ->where('key', $key)
@@ -236,10 +317,10 @@
236 317
237 318 public static function getTicketViewUrl($ticket)
238 319 {
239 320 $baseUrl = self::getPortalBaseUrl();
240 -
241 - return $baseUrl . '/#/ticket/' . $ticket->id . '/view';
321 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
322 + return $baseUrl . '/#/ticket/' . $ticketNumber . '/view';
242 323 }
243 324
244 325 public static function getTicketViewSignedUrl($ticket)
245 326 {
@@ -247,18 +328,139 @@
247 328 return self::getTicketViewUrl($ticket);
248 329 }
249 330
250 331 $baseUrl = self::getPortalBaseUrl();
332 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
251 333
252 334 $baseUrl = add_query_arg([
253 335 'fs_view' => 'ticket',
254 336 'support_hash' => $ticket->hash,
255 - 'ticket_id' => $ticket->id
337 + 'ticket_id' => $ticketNumber,
256 338 ], $baseUrl);
257 339
258 - return $baseUrl . '#/ticket/' . $ticket->id . '/view';
340 + return $baseUrl . '#/ticket/' . $ticketNumber . '/view';
259 341 }
260 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 +
261 463 public static function isPublicSignedTicketEnabled()
262 464 {
263 465 $businessSettings = self::getBusinessSettings();
264 466
@@ -270,34 +472,150 @@
270 472 $baseUrl = self::getPortalAdminBaseUrl();
271 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
272 474 }
273 475
476 + /**
477 + * @return mixed
478 + */
274 479 public static function getPortalBaseUrl()
275 480 {
276 481 $businessSettings = self::getBusinessSettings();
277 - $baseUrl = get_permalink($businessSettings['portal_page_id']);
278 - $baseUrl = rtrim($baseUrl, '/\\');
279 - 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, '/\\'));
280 503 }
281 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 +
282 522 public static function getPortalAdminBaseUrl()
283 523 {
284 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
285 525 }
286 526
287 - 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)
288 536 {
289 537 static $settings;
290 538
291 - if ($settings) {
292 - return $settings;
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
293 541 }
294 542
295 - $settings = (new Settings())->globalBusinessSettings();
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
545 + }
296 546
297 547 return $settings;
298 548 }
299 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 +
300 618 public static function getTicketMeta($ticketId, $key, $default = '')
301 619 {
302 620 $data = Meta::where('object_type', 'ticket_meta')
303 621 ->where('key', $key)
@@ -304,9 +622,9 @@
304 622 ->where('object_id', $ticketId)
305 623 ->first();
306 624
307 625 if ($data) {
308 - $value = maybe_unserialize($data->value);
626 + $value = static::safeUnserialize($data->value);
309 627 if ($value) {
310 628 return $value;
311 629 }
312 630 }
@@ -319,9 +637,9 @@
319 637 $data = Meta::where('object_type', 'ticket_meta')
320 638 ->where('key', $key)
321 639 ->where('object_id', $ticketId)
322 640 ->first();
323 -
641 +
324 642 if ($data) {
325 643 return Meta::where('id', $data->id)
326 644 ->update([
327 645 'value' => maybe_serialize($value)
@@ -342,15 +660,15 @@
342 660 ->table('posts')
343 661 ->select(['ID', 'post_title'])
344 662 ->where('post_type', 'page')
345 663 ->where('post_status', 'publish')
346 - ->orderBy('ID', 'DESC')
664 + ->latest('ID')
347 665 ->get();
348 666 $formattedPages = [];
349 667 foreach ($pages as $page) {
350 668 $formattedPages[] = [
351 - 'id' => strval($page->ID),
352 - 'title' => $page->post_title
669 + 'id' => intval($page->ID),
670 + 'title' => $page->post_title ?: __('(no title)', 'fluent-support')
353 671 ];
354 672 }
355 673 return $formattedPages;
356 674 }
@@ -362,26 +680,54 @@
362 680 if ($mailbox) {
363 681 return $mailbox;
364 682 }
365 683
366 - return MailBox::orderBy('id', 'ASC')->first();
684 + return MailBox::oldest('id')->first();
367 685 }
368 686
369 687 public static function getCurrentAgent()
370 688 {
371 - 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 + }
372 692 }
373 693
374 694 public static function getCurrentCustomer()
375 695 {
376 - 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 + }
377 703 }
378 704
379 705 public static function getCurrentPerson()
380 706 {
381 - 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;
382 713 }
383 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 +
384 730 public static function getFluentCRMTagConfig()
385 731 {
386 732 if (!defined('FLUENTCRM')) {
387 733 return [
@@ -386,8 +732,9 @@
386 732 if (!defined('FLUENTCRM')) {
387 733 return [
388 734 'can_add_tags' => false,
389 735 'tags' => [],
736 + 'lists' => [],
390 737 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
391 738 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
392 739 ];
393 740 }
@@ -395,21 +742,33 @@
395 742 $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
396 743
397 744 $canAddTags = apply_filters('fluent_support/can_user_add_tags_to_customer', $canAddTags);
398 745 $crmTags = [];
746 + $crmLists = [];
399 747 if ($canAddTags) {
400 - $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();
401 750 }
402 751
403 - return [
752 + $crmConfigs = [
404 753 'can_add_tags' => $canAddTags,
405 754 'tags' => $crmTags,
755 + 'lists' => $crmLists,
406 756 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
407 757 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/fluent-crm-icon.png',
408 758 ];
409 759
760 + if (defined('FLUENTCRM')) {
761 + $crmConfigs['contacts'] = []; //(new \FluentCrm\App\Models\Subscriber)->get();
762 + }
763 +
764 + return $crmConfigs;
410 765 }
411 766
767 + /**
768 + * @param Customer $customer
769 + * @return array|false
770 + */
412 771 public static function getFluentCrmContactData($customer)
413 772 {
414 773 if (!defined('FLUENTCRM')) {
415 774 return false;
@@ -416,12 +775,13 @@
416 775 }
417 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
418 777 if ($contact) {
419 778 $tags = $contact->tags;
779 + $lists = $contact->lists;
420 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
421 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
422 782
423 - $contactData = [
783 + return [
424 784 'id' => $contact->id,
425 785 'first_name' => $contact->first_name,
426 786 'last_name' => $contact->last_name,
427 787 'full_name' => $contact->full_name,
@@ -426,16 +786,1201 @@
426 786 'last_name' => $contact->last_name,
427 787 'full_name' => $contact->full_name,
428 788 'name_mismatch' => $contact->full_name != $customer->full_name,
429 789 'tags' => $tags,
790 + 'lists' => $lists,
430 791 'status' => $contact->status,
431 792 'stats' => $contact->stats(),
432 793 'view_url' => $crmProfileUrl
433 794 ];
795 + }
434 796
435 - 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;
436 806 }
437 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 +
438 834 return false;
439 835 }
440 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 + }
441 1986 }