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