PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 All 260 releases
wpfunnels / includes / core / AI / Settings / AISettings.php

AISettings.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/AI/Settings/AISettings.php

518 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AISettings — storage for AI provider connections.
4 *
5 * API keys are encrypted at rest (AES-256-CBC, key derived from the site's auth
6 * salts) and are never returned to the frontend after save — only a masked tail
7 * for display.
8 *
9 * @package WPFunnels\AI
10 * @since 3.13.0
11 */
12
13 namespace WPFunnels\AI\Settings;
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * Class AISettings
19 */
20 class AISettings {
21
22 /**
23 * Option holding the whole AI settings array.
24 */
25 public const OPTION_KEY = '_wpfnl_ai_settings';
26
27 /**
28 * Supported providers.
29 */
30 public const PROVIDERS = [ 'anthropic', 'openai', 'gemini', 'wordpress_ai' ];
31
32 /**
33 * Providers that need an API key. wordpress_ai uses the site-level WP AI Services.
34 */
35 public const REQUIRES_KEY = [ 'anthropic', 'openai', 'gemini' ];
36
37 /**
38 * Default model per provider.
39 */
40 public const DEFAULT_MODELS = [
41 'anthropic' => 'claude-opus-4-8',
42 'openai' => 'gpt-4o',
43 'gemini' => 'gemini-2.0-flash',
44 'wordpress_ai' => 'auto',
45 ];
46
47 /**
48 * Selectable models per provider, shown in the settings UI dropdown.
49 * The first item is the default.
50 */
51 public const MODEL_LISTS = [
52 'anthropic' => [
53 [
54 'id' => 'claude-opus-4-8',
55 'label' => 'Claude Opus 4.8 (Default)',
56 ],
57 [
58 'id' => 'claude-sonnet-4-6',
59 'label' => 'Claude Sonnet 4.6',
60 ],
61 [
62 'id' => 'claude-haiku-4-5-20251001',
63 'label' => 'Claude Haiku 4.5',
64 ],
65 [
66 'id' => 'claude-fable-5',
67 'label' => 'Claude Fable 5',
68 ],
69 [
70 'id' => 'claude-opus-4-6',
71 'label' => 'Claude Opus 4.6',
72 ],
73 ],
74 'openai' => [
75 [
76 'id' => 'gpt-4o',
77 'label' => 'GPT-4o (Default)',
78 ],
79 [
80 'id' => 'gpt-4o-mini',
81 'label' => 'GPT-4o Mini',
82 ],
83 [
84 'id' => 'gpt-4.1',
85 'label' => 'GPT-4.1',
86 ],
87 [
88 'id' => 'gpt-4.1-mini',
89 'label' => 'GPT-4.1 Mini',
90 ],
91 [
92 'id' => 'o4-mini',
93 'label' => 'o4 Mini',
94 ],
95 [
96 'id' => 'o3',
97 'label' => 'o3',
98 ],
99 ],
100 'gemini' => [
101 [
102 'id' => 'gemini-2.0-flash',
103 'label' => 'Gemini 2.0 Flash (Default)',
104 ],
105 [
106 'id' => 'gemini-2.5-flash',
107 'label' => 'Gemini 2.5 Flash',
108 ],
109 [
110 'id' => 'gemini-2.5-pro',
111 'label' => 'Gemini 2.5 Pro',
112 ],
113 [
114 'id' => 'gemini-2.5-flash-lite',
115 'label' => 'Gemini 2.5 Flash Lite',
116 ],
117 ],
118 'wordpress_ai' => [
119 [
120 'id' => 'auto',
121 'label' => 'WordPress Default Model',
122 ],
123 ],
124 ];
125
126 /**
127 * Legacy plaintext key options written by WPFunnels Pro 2.6–2.8.
128 *
129 * @var array<string, string>
130 */
131 private const LEGACY_KEY_OPTIONS = [
132 'openai' => '_wpfunnels_ai_openai_api_key',
133 'anthropic' => '_wpfunnels_ai_anthropic_api_key',
134 ];
135
136 /**
137 * Raw settings array (keys stay encrypted).
138 *
139 * @return array
140 */
141 public static function all() {
142 $settings = get_option( self::OPTION_KEY, [] );
143 return is_array( $settings ) ? $settings : [];
144 }
145
146 /**
147 * Currently active provider, or '' when nothing usable is connected.
148 *
149 * @return string
150 */
151 public static function getActiveProvider() {
152 $settings = self::all();
153 $active = isset( $settings['active_provider'] ) ? $settings['active_provider'] : '';
154 return in_array( $active, self::PROVIDERS, true ) && self::isConnected( $active ) ? $active : '';
155 }
156
157 /**
158 * Master on/off switch. Disabled by default — the admin opts in explicitly.
159 * Toggling off leaves provider connections untouched so they survive a later
160 * re-enable.
161 *
162 * @return bool
163 */
164 public static function isEnabled() {
165 $settings = self::all();
166 return ! empty( $settings['enabled'] );
167 }
168
169 /**
170 * Set the master switch.
171 *
172 * @param bool $enabled Enabled state.
173 * @return void
174 */
175 public static function setEnabled( $enabled ) {
176 $settings = self::all();
177 $settings['enabled'] = (bool) $enabled;
178 update_option( self::OPTION_KEY, $settings, false );
179 }
180
181 /**
182 * Whether a provider has a usable connection.
183 *
184 * @param string $provider Provider slug.
185 * @return bool
186 */
187 public static function isConnected( $provider ) {
188 $settings = self::all();
189 if ( in_array( $provider, self::REQUIRES_KEY, true ) ) {
190 return ! empty( $settings['providers'][ $provider ]['key'] );
191 }
192 // wordpress_ai is "connected" when explicitly activated (no key required).
193 return ! empty( $settings['providers'][ $provider ]['connected'] );
194 }
195
196 /**
197 * Stored model for a provider, falling back to the default.
198 *
199 * @param string $provider Provider slug.
200 * @return string
201 */
202 public static function getModel( $provider ) {
203 $settings = self::all();
204 $model = isset( $settings['providers'][ $provider ]['model'] ) ? $settings['providers'][ $provider ]['model'] : '';
205 if ( is_string( $model ) && '' !== $model ) {
206 return $model;
207 }
208 return isset( self::DEFAULT_MODELS[ $provider ] ) ? self::DEFAULT_MODELS[ $provider ] : '';
209 }
210
211 /**
212 * Decrypted API key for a provider, or '' when not connected / no key needed.
213 *
214 * @param string $provider Provider slug.
215 * @return string
216 */
217 public static function getApiKey( $provider ) {
218 if ( ! in_array( $provider, self::REQUIRES_KEY, true ) ) {
219 return '';
220 }
221 $settings = self::all();
222 $stored = isset( $settings['providers'][ $provider ]['key'] ) ? $settings['providers'][ $provider ]['key'] : '';
223 return is_string( $stored ) && '' !== $stored ? self::decrypt( $stored ) : '';
224 }
225
226 /**
227 * Custom instructions injected into every system prompt.
228 *
229 * @return string
230 */
231 public static function getCustomInstructions() {
232 $settings = self::all();
233 $text = isset( $settings['custom_instructions'] ) ? $settings['custom_instructions'] : '';
234 return is_string( $text ) ? $text : '';
235 }
236
237 /**
238 * Save custom instructions.
239 *
240 * @param string $text Instruction text.
241 * @return void
242 */
243 public static function saveCustomInstructions( $text ) {
244 $settings = self::all();
245 $settings['custom_instructions'] = sanitize_textarea_field( $text );
246 update_option( self::OPTION_KEY, $settings, false );
247 }
248
249 /**
250 * Whether the site opted into borrowing Mail Mint's AI connection.
251 *
252 * Credentials are never copied — AIInit delegates the call to Mail Mint's
253 * own provider adapter when this is on.
254 *
255 * @return bool
256 */
257 public static function usesMailMintConnection() {
258 $settings = self::all();
259 return ! empty( $settings['use_mail_mint_connection'] );
260 }
261
262 /**
263 * Opt in/out of Mail Mint's shared AI connection.
264 *
265 * @param bool $use Whether to delegate to Mail Mint.
266 * @return void
267 */
268 public static function setUsesMailMintConnection( $use ) {
269 $settings = self::all();
270 $settings['use_mail_mint_connection'] = (bool) $use;
271 if ( $settings['use_mail_mint_connection'] ) {
272 $settings['enabled'] = true;
273 }
274 update_option( self::OPTION_KEY, $settings, false );
275 }
276
277 /**
278 * Store a provider connection (key encrypted) and mark it active.
279 * For wordpress_ai the API key is ignored — only availability matters.
280 *
281 * @param string $provider Provider slug.
282 * @param string $api_key Plaintext API key.
283 * @param string $model Optional model id.
284 * @return true|\WP_Error
285 */
286 public static function connect( $provider, $api_key, $model = '' ) {
287 if ( ! in_array( $provider, self::PROVIDERS, true ) ) {
288 return new \WP_Error( 'invalid_provider', __( 'Unknown AI provider.', 'wpfnl' ) );
289 }
290
291 $settings = self::all();
292 $model = '' !== $model ? sanitize_text_field( $model ) : self::DEFAULT_MODELS[ $provider ];
293
294 if ( ! in_array( $provider, self::REQUIRES_KEY, true ) ) {
295 // wordpress_ai — no key storage.
296 $settings['providers'][ $provider ] = [
297 'connected' => true,
298 'model' => $model,
299 ];
300 } else {
301 $encrypted = self::encrypt( $api_key );
302 if ( '' === $encrypted ) {
303 return new \WP_Error(
304 'encryption_failed',
305 __( 'Could not encrypt the API key (openssl unavailable).', 'wpfnl' )
306 );
307 }
308 $settings['providers'][ $provider ] = [
309 'key' => $encrypted,
310 'model' => $model,
311 ];
312 }
313
314 $settings['active_provider'] = $provider;
315 // Connecting a provider opts the admin into the feature.
316 $settings['enabled'] = true;
317 update_option( self::OPTION_KEY, $settings, false );
318 return true;
319 }
320
321 /**
322 * Remove a provider connection, promoting another connected provider if the
323 * removed one was active.
324 *
325 * @param string $provider Provider slug.
326 * @return void
327 */
328 public static function disconnect( $provider ) {
329 $settings = self::all();
330 unset( $settings['providers'][ $provider ] );
331 update_option( self::OPTION_KEY, $settings, false );
332
333 if ( ( isset( $settings['active_provider'] ) ? $settings['active_provider'] : '' ) !== $provider ) {
334 return;
335 }
336
337 $settings['active_provider'] = '';
338 foreach ( self::PROVIDERS as $candidate ) {
339 if ( self::isConnected( $candidate ) ) {
340 $settings['active_provider'] = $candidate;
341 break;
342 }
343 }
344 update_option( self::OPTION_KEY, $settings, false );
345 }
346
347 /**
348 * Switch the active provider. Only connected providers can be activated.
349 *
350 * @param string $provider Provider slug.
351 * @return bool
352 */
353 public static function setActiveProvider( $provider ) {
354 if ( ! self::isConnected( $provider ) ) {
355 return false;
356 }
357 $settings = self::all();
358 $settings['active_provider'] = $provider;
359 $settings['enabled'] = true;
360 update_option( self::OPTION_KEY, $settings, false );
361 return true;
362 }
363
364 /**
365 * Update the stored model for a connected provider without re-entering the key.
366 *
367 * @param string $provider Provider slug.
368 * @param string $model Model id.
369 * @return bool
370 */
371 public static function updateModel( $provider, $model ) {
372 if ( ! self::isConnected( $provider ) ) {
373 return false;
374 }
375 $settings = self::all();
376 $settings['providers'][ $provider ]['model'] = sanitize_text_field( $model );
377 update_option( self::OPTION_KEY, $settings, false );
378 return true;
379 }
380
381 /**
382 * Frontend-safe view: connection status + masked key tail per provider.
383 * Never exposes a decrypted key.
384 *
385 * @return array
386 */
387 public static function publicState() {
388 $providers = [];
389 foreach ( self::PROVIDERS as $provider ) {
390 $key = self::getApiKey( $provider );
391 $providers[ $provider ] = [
392 'connected' => self::isConnected( $provider ),
393 'masked_key' => ( '' !== $key ) ? '••••' . substr( $key, -4 ) : '',
394 'model' => self::getModel( $provider ),
395 'model_list' => isset( self::MODEL_LISTS[ $provider ] ) ? self::MODEL_LISTS[ $provider ] : [],
396 'requires_key' => in_array( $provider, self::REQUIRES_KEY, true ),
397 ];
398 }
399
400 return [
401 'enabled' => self::isEnabled(),
402 'active_provider' => self::getActiveProvider(),
403 'providers' => $providers,
404 'custom_instructions' => self::getCustomInstructions(),
405 'use_mail_mint_connection' => self::usesMailMintConnection(),
406 'mail_mint_available' => class_exists( '\Mint\MRM\Internal\AI\AIInit' ),
407 ];
408 }
409
410 // -------------------------------------------------------------------------
411 // Legacy migration
412 // -------------------------------------------------------------------------
413
414 /**
415 * Migrate the plaintext API keys written by WPFunnels Pro 2.6–2.8 into the
416 * encrypted store, then delete the plaintext options.
417 *
418 * Idempotent: a `legacy_migrated` flag stops it from running twice, and it
419 * never overwrites a connection that already exists here.
420 *
421 * @return bool True when a migration ran.
422 */
423 public static function migrateLegacyKeys() {
424 $settings = self::all();
425 if ( ! empty( $settings['legacy_migrated'] ) ) {
426 return false;
427 }
428
429 $migrated = false;
430
431 foreach ( self::LEGACY_KEY_OPTIONS as $provider => $option_name ) {
432 $legacy_key = get_option( $option_name, '' );
433
434 if ( is_string( $legacy_key ) && '' !== trim( $legacy_key ) && ! self::isConnected( $provider ) ) {
435 $legacy_model = get_option( '_wpfunnels_ai_' . $provider . '_text_model', '' );
436 $result = self::connect( $provider, trim( $legacy_key ), is_string( $legacy_model ) ? $legacy_model : '' );
437
438 if ( true === $result ) {
439 $migrated = true;
440 // The legacy enable flag decides whether the feature stays on.
441 self::setEnabled( (bool) get_option( '_wpfunnels_ai_enabled', false ) );
442 }
443 }
444
445 // Plaintext keys must not survive the migration, migrated or not.
446 delete_option( $option_name );
447 }
448
449 // Carry the legacy active provider over when it is usable.
450 $legacy_provider = get_option( '_wpfunnels_ai_provider', '' );
451 if ( is_string( $legacy_provider ) && self::isConnected( $legacy_provider ) ) {
452 self::setActiveProvider( $legacy_provider );
453 }
454
455 $settings = self::all();
456 $settings['legacy_migrated'] = true;
457 update_option( self::OPTION_KEY, $settings, false );
458
459 return $migrated;
460 }
461
462 // -------------------------------------------------------------------------
463 // Crypto
464 // -------------------------------------------------------------------------
465
466 /**
467 * Derive the encryption key from the site's auth salts.
468 *
469 * @return string Raw 32-byte key.
470 */
471 private static function encryptionKey() {
472 $salt = ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : '' );
473 if ( '' === $salt ) {
474 $salt = wp_salt( 'auth' );
475 }
476 return hash( 'sha256', 'wpfunnels-ai|' . $salt, true );
477 }
478
479 /**
480 * Encrypt a plaintext value.
481 *
482 * @param string $plaintext Value to encrypt.
483 * @return string Base64 of IV + ciphertext, or '' on failure.
484 */
485 private static function encrypt( $plaintext ) {
486 if ( '' === $plaintext || ! function_exists( 'openssl_encrypt' ) ) {
487 return '';
488 }
489 $iv = random_bytes( 16 );
490 $cipher = openssl_encrypt( $plaintext, 'aes-256-cbc', self::encryptionKey(), OPENSSL_RAW_DATA, $iv );
491 return false === $cipher ? '' : base64_encode( $iv . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
492 }
493
494 /**
495 * Decrypt a stored value.
496 *
497 * @param string $stored Base64 of IV + ciphertext.
498 * @return string Plaintext, or '' on failure.
499 */
500 private static function decrypt( $stored ) {
501 if ( '' === $stored || ! function_exists( 'openssl_decrypt' ) ) {
502 return '';
503 }
504 $raw = base64_decode( $stored, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
505 if ( false === $raw || strlen( $raw ) <= 16 ) {
506 return '';
507 }
508 $plain = openssl_decrypt(
509 substr( $raw, 16 ),
510 'aes-256-cbc',
511 self::encryptionKey(),
512 OPENSSL_RAW_DATA,
513 substr( $raw, 0, 16 )
514 );
515 return false === $plain ? '' : $plain;
516 }
517 }
518