| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | use FluentSupport\App\Models\Meta; |
| 8 | 8 | use FluentSupport\App\Models\Product; |
| 9 | 9 | use FluentSupport\App\Services\EmailNotification\Settings; |
| 10 | 10 | use FluentSupport\App\Services\Helper; |
| 11 | +use FluentSupport\App\Services\Integrations\AI\AIProviderFactory; | |
| 11 | 12 | use FluentSupport\Database\Migrations\AIActivityLogsMigrator; |
| 12 | 13 | use FluentSupport\Framework\Http\Request\Request; |
| 13 | 14 | use FluentSupport\App\Hooks\Handlers\ReCaptchaHandler; |
| 14 | 15 | use FluentSupport\Framework\Support\Arr; |
| @@ -360,129 +361,103 @@ | ||
| 360 | 361 | 'message' => __('Your reCAPTCHA settings added successfully.', 'fluent-support'), |
| 361 | 362 | ]); |
| 362 | 363 | } |
| 363 | 364 | |
| 364 | - public function saveOpenAISettings(Request $request) | |
| 365 | + public function saveAIProviderSettings(Request $request) | |
| 365 | 366 | { |
| 366 | - $data = [ | |
| 367 | - 'api_key' => $request->getSafe('api_key', 'sanitize_text_field', ''), | |
| 368 | - 'model' => $request->getSafe('model', 'sanitize_text_field', ''), | |
| 369 | - ]; | |
| 367 | + $allowedProviders = AIProviderFactory::getAllowedProviders(); | |
| 368 | + $provider = $request->getSafe('provider', 'sanitize_text_field', 'openai'); | |
| 370 | 369 | |
| 371 | - $response = Helper::authorizeChatGPTAPIKey($data); | |
| 372 | - | |
| 373 | - if (is_wp_error($response)) { | |
| 370 | + if (!in_array($provider, $allowedProviders, true)) { | |
| 374 | 371 | return $this->sendError([ |
| 375 | - 'message' => __('There was an error verifying the API key.', 'fluent-support'), | |
| 372 | + 'message' => __('Invalid AI provider selected.', 'fluent-support'), | |
| 376 | 373 | ]); |
| 377 | 374 | } |
| 378 | 375 | |
| 379 | - $body = json_decode(wp_remote_retrieve_body($response), true); | |
| 376 | + $enabled = $request->getSafe('enabled', 'sanitize_text_field', 'yes'); | |
| 377 | + $apiKey = $request->getSafe('api_key', 'sanitize_text_field', ''); | |
| 378 | + $model = $request->getSafe('model', 'sanitize_text_field', ''); | |
| 380 | 379 | |
| 381 | - if (isset($body['error'])) { | |
| 382 | - return $this->sendError([ | |
| 383 | - 'message' => __('Invalid API key. Please provide a valid ChatGPT API key.', 'fluent-support'), | |
| 384 | - ]); | |
| 380 | + if (empty($apiKey) || strpos($apiKey, '****') === 0) { | |
| 381 | + $existing = Helper::getAIProviderSettings(); | |
| 382 | + if (($existing['provider'] ?? '') === $provider) { | |
| 383 | + $apiKey = $existing['api_key'] ?? ''; | |
| 384 | + } else { | |
| 385 | + $apiKey = ''; | |
| 386 | + } | |
| 385 | 387 | } |
| 386 | 388 | |
| 389 | + $data = [ | |
| 390 | + 'enabled' => $enabled, | |
| 391 | + 'provider' => $provider, | |
| 392 | + 'api_key' => $apiKey, | |
| 393 | + 'model' => $model, | |
| 394 | + ]; | |
| 395 | + | |
| 387 | 396 | try { |
| 388 | - $isDataSaved = Helper::saveOpenAIData('_fs_openai_settings', '_fs_openai_data', $data); | |
| 389 | - if ($isDataSaved) { | |
| 390 | - AIActivityLogsMigrator::migrate(); | |
| 391 | - } | |
| 397 | + Helper::saveAIProviderSettings($data); | |
| 398 | + AIProviderFactory::clearCache(); | |
| 399 | + AIActivityLogsMigrator::migrate(); | |
| 400 | + | |
| 392 | 401 | return $this->sendSuccess([ |
| 393 | - 'message' => __('OpenAI settings have been successfully saved.', 'fluent-support'), | |
| 402 | + 'message' => __('AI settings have been successfully saved.', 'fluent-support'), | |
| 394 | 403 | ]); |
| 395 | 404 | } catch (\Exception $e) { |
| 396 | - // translators: %s is the error message from the exception | |
| 397 | - $translatedMessage = __('An error occurred while saving the settings: %s', 'fluent-support'); | |
| 398 | - $errorMessage = sprintf($translatedMessage, Helper::getSafeErrorMessage($e)); | |
| 399 | - | |
| 400 | 405 | return $this->sendError([ |
| 401 | - 'message' => $errorMessage, | |
| 406 | + 'message' => sprintf( | |
| 407 | + __('An error occurred while saving the settings: %s', 'fluent-support'), | |
| 408 | + Helper::getSafeErrorMessage($e) | |
| 409 | + ), | |
| 402 | 410 | ]); |
| 403 | 411 | } |
| 404 | 412 | } |
| 405 | 413 | |
| 406 | - | |
| 407 | - public function disconnectOpenAI() | |
| 414 | + public function disconnectAIProvider() | |
| 408 | 415 | { |
| 409 | - $deletedRecords = Meta::where([ | |
| 416 | + Meta::where('object_type', '_fs_ai_provider_settings')->delete(); | |
| 417 | + Meta::where([ | |
| 410 | 418 | 'object_type' => '_fs_openai_settings', |
| 411 | 419 | 'key' => '_fs_openai_data', |
| 412 | 420 | ])->delete(); |
| 413 | 421 | |
| 414 | - if ($deletedRecords) { | |
| 415 | - return $this->sendSuccess([ | |
| 416 | - 'message' => __('OpenAI settings have been successfully disconnected.', 'fluent-support'), | |
| 417 | - ]); | |
| 418 | - } else { | |
| 419 | - return $this->sendError([ | |
| 420 | - 'message' => __('Failed to disconnect OpenAI settings. No matching records found or an error occurred.', 'fluent-support'), | |
| 421 | - ]); | |
| 422 | - } | |
| 422 | + AIProviderFactory::clearCache(); | |
| 423 | + | |
| 424 | + return $this->sendSuccess([ | |
| 425 | + 'message' => __('AI provider settings have been successfully disconnected.', 'fluent-support'), | |
| 426 | + ]); | |
| 423 | 427 | } |
| 424 | 428 | |
| 425 | - public function getOpenAISettings() | |
| 429 | + public function getAIProviderSettings() | |
| 426 | 430 | { |
| 427 | - $modelOptions = $this->getOpenAIModelOptions(); | |
| 428 | - $supportedModels = array_column($modelOptions, 'value'); | |
| 431 | + $settings = Helper::getAIProviderSettings(); | |
| 429 | 432 | |
| 430 | - $settings = [ | |
| 431 | - 'api_key' => '', | |
| 432 | - 'model' => 'gpt-5.2', | |
| 433 | - ]; | |
| 433 | + $provider = $settings['provider'] ?? 'openai'; | |
| 434 | + $apiKey = $settings['api_key'] ?? ''; | |
| 435 | + $model = $settings['model'] ?? ''; | |
| 436 | + $defaultEnabled = !empty($apiKey) ? 'yes' : 'no'; | |
| 437 | + $enabled = $settings['enabled'] ?? $defaultEnabled; | |
| 434 | 438 | |
| 435 | - $chatGPTSettingsData = Meta::where('object_type', '_fs_openai_settings')->first(); | |
| 436 | - if ($chatGPTSettingsData) { | |
| 437 | - $settings = Helper::safeUnserialize($chatGPTSettingsData->value); | |
| 438 | - | |
| 439 | - if (!empty($settings['model']) && !in_array($settings['model'], $supportedModels, true)) { | |
| 440 | - $previousModel = $settings['model']; | |
| 441 | - $settings['model'] = 'gpt-5.2'; | |
| 442 | - Helper::saveOpenAIData('_fs_openai_settings', '_fs_openai_data', $settings); | |
| 443 | - $settings['previous_model'] = $previousModel; | |
| 444 | - $settings['model_migrated'] = true; | |
| 439 | + $availableModels = []; | |
| 440 | + foreach (['openai', 'gemini', 'anthropic'] as $p) { | |
| 441 | + $instance = AIProviderFactory::make($p, '', ''); | |
| 442 | + if (!is_wp_error($instance)) { | |
| 443 | + $availableModels[$p] = $instance->getAvailableModels(); | |
| 445 | 444 | } |
| 446 | 445 | } |
| 447 | 446 | |
| 448 | - $settings['model_options'] = $modelOptions; | |
| 447 | + if (empty($model) && !empty($availableModels[$provider])) { | |
| 448 | + $model = $availableModels[$provider][0]['value']; | |
| 449 | + } | |
| 449 | 450 | |
| 450 | - return $this->sendSuccess($settings); | |
| 451 | + return $this->sendSuccess([ | |
| 452 | + 'enabled' => $enabled, | |
| 453 | + 'provider' => $provider, | |
| 454 | + 'api_key' => !empty($apiKey) ? '****' . substr($apiKey, -4) : '', | |
| 455 | + 'model' => $model, | |
| 456 | + 'available_models' => $availableModels, | |
| 457 | + ]); | |
| 451 | 458 | } |
| 452 | 459 | |
| 453 | - private function getOpenAIModelOptions() | |
| 454 | - { | |
| 455 | - $models = [ | |
| 456 | - ['value' => 'gpt-5.2', 'label' => 'GPT-5.2'], | |
| 457 | - ['value' => 'gpt-5.2-chat-latest', 'label' => 'GPT-5.2 Chat'], | |
| 458 | - ['value' => 'gpt-4.1', 'label' => 'GPT-4.1'], | |
| 459 | - ['value' => 'gpt-4.1-mini', 'label' => 'GPT-4.1 Mini'], | |
| 460 | - ['value' => 'gpt-4.1-nano', 'label' => 'GPT-4.1 Nano'], | |
| 461 | - ['value' => 'gpt-4o', 'label' => 'GPT-4o'], | |
| 462 | - ['value' => 'gpt-4o-mini', 'label' => 'GPT-4o Mini'], | |
| 463 | - ['value' => 'gpt-4o-2024-08-06', 'label' => 'GPT-4o (2024-08-06)'], | |
| 464 | - ['value' => 'gpt-4o-2024-05-13', 'label' => 'GPT-4o (2024-05-13)'], | |
| 465 | - ['value' => 'gpt-4o-mini-2024-07-18', 'label' => 'GPT-4o Mini (2024-07-18)'], | |
| 466 | - ['value' => 'gpt-4-turbo', 'label' => 'GPT-4 Turbo'], | |
| 467 | - ['value' => 'gpt-4-turbo-2024-04-09', 'label' => 'GPT-4 Turbo (2024-04-09)'], | |
| 468 | - ['value' => 'gpt-4-turbo-preview', 'label' => 'GPT-4 Turbo Preview'], | |
| 469 | - ['value' => 'gpt-4', 'label' => 'GPT-4'], | |
| 470 | - ['value' => 'gpt-4-0613', 'label' => 'GPT-4 (0613)'], | |
| 471 | - ['value' => 'gpt-3.5-turbo', 'label' => 'GPT-3.5 Turbo'], | |
| 472 | - ['value' => 'gpt-3.5-turbo-0125', 'label' => 'GPT-3.5 Turbo (0125)'], | |
| 473 | - ['value' => 'o3', 'label' => 'o3'], | |
| 474 | - ['value' => 'o3-mini', 'label' => 'o3-mini'], | |
| 475 | - ['value' => 'o4-mini', 'label' => 'o4-mini'], | |
| 476 | - ['value' => 'o1', 'label' => 'o1'], | |
| 477 | - ['value' => 'gpt-4-0314', 'label' => 'GPT-4 (0314) - Deprecated soon'], | |
| 478 | - ['value' => 'gpt-4-1106-preview', 'label' => 'GPT-4 (1106 Preview) - Deprecated soon'], | |
| 479 | - ['value' => 'gpt-4-0125-preview', 'label' => 'GPT-4 (0125 Preview) - Deprecated soon'], | |
| 480 | - ]; | |
| 481 | - | |
| 482 | - return apply_filters('fluent_support/supported_openai_models', $models); | |
| 483 | - } | |
| 484 | - | |
| 485 | 460 | public function getReCaptchaSettings() |
| 486 | 461 | { |
| 487 | 462 | $reCaptchaSettingsData = Meta::where('object_type', '_fs_recaptcha_settings')->first(); |
| 488 | 463 | if ($reCaptchaSettingsData) { |
| @@ -521,11 +496,9 @@ | ||
| 521 | 496 | ]; |
| 522 | 497 | |
| 523 | 498 | $url = add_query_arg($data, 'https://wpmanageninja.com/'); |
| 524 | 499 | |
| 525 | - wp_remote_post($url, [ | |
| 526 | - 'sslverify' => false | |
| 527 | - ]); | |
| 500 | + wp_remote_post($url); | |
| 528 | 501 | } |
| 529 | 502 | |
| 530 | 503 | /** |
| 531 | 504 | * installFluentCRM method will install Fluent CRM plugin |
| @@ -760,8 +733,9 @@ | ||
| 760 | 733 | 'title' => 'Dropbox', |
| 761 | 734 | 'has_config' => true, |
| 762 | 735 | 'is_configured' => $dropBoxConfigured, |
| 763 | 736 | 'require_pro' => !defined('FLUENTSUPPORTPRO'), |
| 737 | + 'upgrade_url' => Helper::getUpgradeUrl('feature_lock_dropbox'), | |
| 764 | 738 | 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/dbox.svg', |
| 765 | 739 | 'description' => __('Upload and store the files to your Dropbox Storage.', 'fluent-support') |
| 766 | 740 | ], |
| 767 | 741 | 'google_drive' => [ |
| @@ -769,9 +743,9 @@ | ||
| 769 | 743 | 'title' => 'Google Drive', |
| 770 | 744 | 'has_config' => true, |
| 771 | 745 | 'is_configured' => $googleDriveConfigured, |
| 772 | 746 | 'require_pro' => !defined('FLUENTSUPPORTPRO'), |
| 773 | - 'upgrade_url' => 'https://fluentsupport.com/pricing', | |
| 747 | + 'upgrade_url' => Helper::getUpgradeUrl('feature_lock_google_drive'), | |
| 774 | 748 | 'description' => __('Upload and store the files to your Google Drive Storage.', 'fluent-support'), |
| 775 | 749 | 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/drive.svg', |
| 776 | 750 | ], |
| 777 | 751 | 'cloudflare_r2' => [ |
| @@ -779,9 +753,9 @@ | ||
| 779 | 753 | 'title' => 'Cloudflare R2', |
| 780 | 754 | 'has_config' => true, |
| 781 | 755 | 'is_configured' => $cloudflareR2Configured, |
| 782 | 756 | 'require_pro' => !defined('FLUENTSUPPORTPRO'), |
| 783 | - 'upgrade_url' => 'https://fluentsupport.com/pricing', | |
| 757 | + 'upgrade_url' => Helper::getUpgradeUrl('feature_lock_cloudflare_r2'), | |
| 784 | 758 | 'description' => __('Upload and store the files to Cloudflare R2 Storage with zero egress fees.', 'fluent-support'), |
| 785 | 759 | 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/cloudflare-r2.svg', |
| 786 | 760 | ], |
| 787 | 761 | 'amazon_s3' => [ |
| @@ -789,9 +763,9 @@ | ||
| 789 | 763 | 'title' => 'Amazon S3', |
| 790 | 764 | 'has_config' => true, |
| 791 | 765 | 'is_configured' => $amazonS3Configured, |
| 792 | 766 | 'require_pro' => !defined('FLUENTSUPPORTPRO'), |
| 793 | - 'upgrade_url' => 'https://fluentsupport.com/pricing', | |
| 767 | + 'upgrade_url' => Helper::getUpgradeUrl('feature_lock_amazon_s3'), | |
| 794 | 768 | 'description' => __('Upload and store the files to Amazon S3 cloud storage.', 'fluent-support'), |
| 795 | 769 | 'icon' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/amazon-s3.svg', |
| 796 | 770 | ] |
| 797 | 771 | ]); |
| @@ -842,10 +816,16 @@ | ||
| 842 | 816 | if (!is_array($settings)) { |
| 843 | 817 | $settings = []; |
| 844 | 818 | } |
| 845 | 819 | |
| 820 | + // Write-only secret: never return the raw team API key to the browser | |
| 821 | + // (it authenticates the FluentBot API and would leak to logs/extensions/ | |
| 822 | + // XSS). Expose only whether a key is stored so the form can show a | |
| 823 | + // "saved" hint; the input stays blank and only overwrites on a new key. | |
| 824 | + $hasApiKey = !empty($settings['generalApiKey']); | |
| 846 | 825 | unset($settings['generalApiKey']); |
| 847 | 826 | |
| 827 | + // Per-mapping apiKey is unused (the key is team-wide). | |
| 848 | 828 | if (!empty($settings['productMappings']) && is_array($settings['productMappings'])) { |
| 849 | 829 | $settings['productMappings'] = array_map(function ($mapping) { |
| 850 | 830 | if (!is_array($mapping)) { |
| 851 | 831 | return $mapping; |
| @@ -854,9 +834,9 @@ | ||
| 854 | 834 | return $mapping; |
| 855 | 835 | }, $settings['productMappings']); |
| 856 | 836 | } |
| 857 | 837 | |
| 858 | - $productItems = Product::all()->map(function ($product) { | |
| 838 | + $productItems = Product::orderedByTitle()->get()->map(function ($product) { | |
| 859 | 839 | return [ |
| 860 | 840 | 'id' => $product->id, |
| 861 | 841 | 'title' => $product->title |
| 862 | 842 | ]; |
| @@ -865,8 +845,9 @@ | ||
| 865 | 845 | // Default generalBotEnabled to true for backward compatibility with configs saved |
| 866 | 846 | // before this flag existed — existing installs expect general bot to work on GET. |
| 867 | 847 | $defaults = [ |
| 868 | 848 | 'generalBotId' => '', |
| 849 | + 'generalApiKey' => '', | |
| 869 | 850 | 'generalBotEnabled' => true, |
| 870 | 851 | 'isEnabled' => false, |
| 871 | 852 | 'productMappings' => [], |
| 872 | 853 | 'products' => $productItems, |
| @@ -872,16 +853,35 @@ | ||
| 872 | 853 | 'products' => $productItems, |
| 873 | 854 | ]; |
| 874 | 855 | |
| 875 | 856 | return array_merge($defaults, $settings, [ |
| 876 | - 'products' => $productItems | |
| 857 | + 'products' => $productItems, | |
| 858 | + 'hasApiKey' => $hasApiKey, | |
| 877 | 859 | ]); |
| 878 | 860 | } |
| 879 | 861 | |
| 880 | 862 | public function saveFluentBotSettings(Request $request) |
| 881 | 863 | { |
| 864 | + $where = [ | |
| 865 | + 'object_type' => 'fluent_bot_settings', | |
| 866 | + 'object_id' => 1, | |
| 867 | + 'key' => '_fs_fluent_bot_config' | |
| 868 | + ]; | |
| 869 | + | |
| 870 | + $existing = Meta::where($where)->orderByDesc('id')->first(); | |
| 871 | + $existingConfig = $existing ? Helper::safeUnserialize($existing->value) : []; | |
| 872 | + if (!is_array($existingConfig)) { | |
| 873 | + $existingConfig = []; | |
| 874 | + } | |
| 875 | + | |
| 876 | + // Write-only key: a blank submission means "keep the stored key" (the form | |
| 877 | + // never round-trips the secret), so only overwrite when a new key is sent. | |
| 878 | + $submittedApiKey = trim($request->getSafe('generalApiKey', 'sanitize_text_field')); | |
| 879 | + $apiKey = $submittedApiKey !== '' ? $submittedApiKey : ($existingConfig['generalApiKey'] ?? ''); | |
| 880 | + | |
| 882 | 881 | $data = [ |
| 883 | 882 | 'generalBotId' => $request->getSafe('generalBotId', 'sanitize_text_field'), |
| 883 | + 'generalApiKey' => $apiKey, | |
| 884 | 884 | 'generalBotEnabled' => filter_var($request->get('generalBotEnabled', true), FILTER_VALIDATE_BOOLEAN), |
| 885 | 885 | 'isEnabled' => $request->getSafe('isEnabled', 'rest_sanitize_boolean'), |
| 886 | 886 | 'productMappings' => [] |
| 887 | 887 | ]; |
| @@ -917,16 +917,8 @@ | ||
| 917 | 917 | } |
| 918 | 918 | |
| 919 | 919 | $serialized = maybe_serialize($data); |
| 920 | 920 | |
| 921 | - $where = [ | |
| 922 | - 'object_type' => 'fluent_bot_settings', | |
| 923 | - 'object_id' => 1, | |
| 924 | - 'key' => '_fs_fluent_bot_config' | |
| 925 | - ]; | |
| 926 | - | |
| 927 | - $existing = Meta::where($where)->orderByDesc('id')->first(); | |
| 928 | - | |
| 929 | 921 | if ($existing) { |
| 930 | 922 | // Update the latest row; do not prune siblings — concurrent first-writes could |
| 931 | 923 | // race and delete each other's inserts, leaving zero rows (data loss). |
| 932 | 924 | // Reads use orderByDesc('id')->first() so duplicates are harmless at read time. |
| @@ -935,12 +927,17 @@ | ||
| 935 | 927 | Meta::create(array_merge($where, ['value' => $serialized])); |
| 936 | 928 | AIActivityLogsMigrator::migrate(); |
| 937 | 929 | } |
| 938 | 930 | |
| 931 | + // Write-only: never echo the raw key back to the browser. | |
| 932 | + $responseData = $data; | |
| 933 | + unset($responseData['generalApiKey']); | |
| 934 | + $responseData['hasApiKey'] = $apiKey !== ''; | |
| 935 | + | |
| 939 | 936 | return [ |
| 940 | 937 | 'success' => true, |
| 941 | 938 | 'message' => 'Settings saved successfully', |
| 942 | - 'data' => $data | |
| 939 | + 'data' => $responseData | |
| 943 | 940 | ]; |
| 944 | 941 | } |
| 945 | 942 | |
| 946 | 943 | public function getFluentBotPresets() |