Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Helper
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 10
3306
0.00% covered (danger)
0.00%
0 / 1
 get_status_permission_check
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 is_enabled
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 is_ai_chat_enabled
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 transient_name_for_image_generation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transient_name_for_completion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transient_name_for_ai_assistance_feature
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mark_post_as_ai_assisted
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 get_gpt_completion
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
272
 get_dalle_generation
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
156
 get_ai_assistance_feature
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2/**
3 * API helper for the AI blocks.
4 *
5 * @package automattic/jetpack
6 * @since 11.8
7 */
8
9use Automattic\Jetpack\Connection\Client;
10use Automattic\Jetpack\Connection\Manager;
11use Automattic\Jetpack\Search\Plan as Search_Plan;
12use Automattic\Jetpack\Status;
13use Automattic\Jetpack\Status\Visitor;
14
15/**
16 * Class Jetpack_AI_Helper
17 *
18 * @since 11.8
19 */
20class Jetpack_AI_Helper {
21    /**
22     * Allow new completion every X seconds. Will return cached result otherwise.
23     *
24     * @var int
25     */
26    public static $text_completion_cooldown_seconds = 15;
27
28    /**
29     * Cache images for a prompt for a month.
30     *
31     * @var int
32     */
33    public static $image_generation_cache_timeout = MONTH_IN_SECONDS;
34
35    /**
36     * Cache AI-assistant feature for 60 seconds.
37     *
38     * @var int
39     */
40    public static $ai_assistant_feature_cache_timeout = 60;
41
42    /**
43     * Cache AI-assistant errors for ten seconds.
44     *
45     * @var int
46     */
47    public static $ai_assistant_feature_error_cache_timeout = 10;
48
49    /**
50     * Stores the number of JetpackAI calls in case we want to mark AI-assisted posts some way.
51     *
52     * @var int
53     */
54    public static $post_meta_with_ai_generation_number = '_jetpack_ai_calls';
55
56    /**
57     * Storing the error to prevent repeated requests to WPCOM after failure.
58     *
59     * @var null|WP_Error
60     */
61    private static $ai_assistant_failed_request = null;
62
63    /**
64     * Checks if a given request is allowed to get AI data from WordPress.com.
65     *
66     * @param WP_REST_Request $request Full details about the request.
67     *
68     * @return true|WP_Error True if the request has access, WP_Error object otherwise.
69     */
70    public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
71
72        /*
73         * This may need to be updated
74         * to take into account the different ways we can make requests
75         * (from a WordPress.com site, from a Jetpack site).
76         */
77        if ( ! current_user_can( 'edit_posts' ) ) {
78            return new WP_Error(
79                'rest_forbidden',
80                __( 'Sorry, you are not allowed to access Jetpack AI help on this site.', 'jetpack' ),
81                array( 'status' => rest_authorization_required_code() )
82            );
83        }
84
85        return true;
86    }
87
88    /**
89     * Return true if these features should be active on the current site.
90     * Currently, it's limited to WPCOM Simple and Atomic.
91     */
92    public static function is_enabled() {
93        $default = false;
94
95        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
96            $default = true;
97        } elseif ( ( new Automattic\Jetpack\Status\Host() )->is_woa_site() ) {
98            $default = true;
99        }
100
101        /**
102         * Filter whether the AI features are enabled in the Jetpack plugin.
103         *
104         * @since 11.8
105         *
106         * @param bool $default Are AI features enabled? Defaults to false.
107         */
108        return apply_filters( 'jetpack_ai_enabled', $default );
109    }
110
111    /**
112     * Return true if the AI chat feature should be active on the current site.
113     *
114     * @todo IS_WPCOM (the endpoints need to be updated too).
115     *
116     * @return bool
117     */
118    public static function is_ai_chat_enabled() {
119        $default = false;
120
121        $connection = new Manager();
122        $plan       = new Search_Plan();
123        if ( $connection->is_connected() && $plan->supports_search() ) {
124            $default = true;
125        }
126
127        /**
128         * Filter whether the AI chat feature is enabled in the Jetpack plugin.
129         *
130         * @since 12.6
131         *
132         * @param bool $default Is AI chat enabled? Defaults to false.
133         */
134        return apply_filters( 'jetpack_ai_chat_enabled', $default );
135    }
136
137    /**
138     * Get the name of the transient for image generation. Unique per prompt and allows for reuse of results for the same prompt across entire WPCOM.
139     * I expext "puppy" to always be from cache.
140     *
141     * @param  string $prompt - Supplied prompt.
142     */
143    public static function transient_name_for_image_generation( $prompt ) {
144        return 'jetpack_openai_image_' . md5( $prompt );
145    }
146
147    /**
148     * Get the name of the transient for text completion. Unique per user, but not per text. Serves more as a cooldown.
149     */
150    public static function transient_name_for_completion() {
151        return 'jetpack_openai_completion_' . get_current_user_id(); // Cache for each user, so that other users dont get weird cached version from somebody else.
152    }
153
154    /**
155     * Get the name of the transient for AI assistance feature. Unique per user.
156     *
157     * @param  int $blog_id - Blog ID to get the transient name for.
158     * @return string
159     */
160    public static function transient_name_for_ai_assistance_feature( $blog_id ) {
161        return 'jetpack_openai_ai_assistance_feature_' . $blog_id;
162    }
163
164    /**
165     * Mark the edited post as "touched" by AI stuff.
166     *
167     * @param  int $post_id Post ID for which the content is being generated.
168     * @return void
169     */
170    private static function mark_post_as_ai_assisted( $post_id ) {
171        if ( ! $post_id ) {
172            return;
173        }
174        $previous = get_post_meta( $post_id, self::$post_meta_with_ai_generation_number, true );
175        if ( ! $previous ) {
176            $previous = 0;
177        } elseif ( ! is_numeric( $previous ) ) {
178            // Data corrupted, nothing to do.
179            return;
180        }
181        $new_value = intval( $previous ) + 1;
182        update_post_meta( $post_id, self::$post_meta_with_ai_generation_number, $new_value );
183    }
184
185    /**
186     * Get text back from WordPress.com based off a starting text.
187     *
188     * @param  string $content    The content provided to send to the AI.
189     * @param  int    $post_id    Post ID for which the content is being generated.
190     * @param  bool   $skip_cache Skip cache and force a new request.
191     * @return mixed
192     */
193    public static function get_gpt_completion( $content, $post_id, $skip_cache = false ) {
194        $content = wp_strip_all_tags( $content );
195        $cache   = get_transient( self::transient_name_for_completion() );
196        if ( $cache && ! $skip_cache ) {
197            return $cache;
198        }
199
200        if ( ( new Status() )->is_offline_mode() ) {
201            return new WP_Error(
202                'dev_mode',
203                __( 'Jetpack AI is not available in offline mode.', 'jetpack' )
204            );
205        }
206
207        $wp_disconnected_message = __( 'Your account must be connected to WordPress.com to generate AI content. Please connect your account from the Jetpack settings screen to proceed.', 'jetpack' );
208
209        $site_id = Manager::get_site_id();
210        if ( is_wp_error( $site_id ) ) {
211            // If the site is not connected, return a more helpful error message.
212            if ( 'unavailable_site_id' === $site_id->get_error_code() ) {
213                return new WP_Error(
214                    'unavailable_site_id',
215                    $wp_disconnected_message,
216                    array( 'status' => 403 )
217                );
218            }
219            return $site_id;
220        }
221
222        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
223            if ( ! class_exists( 'OpenAI' ) ) {
224                \require_lib( 'openai' );
225            }
226
227            // Set the content for chatGPT endpoint
228            $data = array(
229                array(
230                    'role'    => 'user',
231                    'content' => $content,
232                ),
233            );
234
235            $openai            = new OpenAI( 'openai', array( 'post_id' => $post_id ) );
236            $moderation_result = $openai->moderate(
237                implode(
238                    ' ',
239                    array_map(
240                        function ( $msg ) {
241                            return $msg['role'] === 'user' ? $msg['content'] : '';
242                        },
243                        $data
244                    )
245                )
246            );
247
248            if ( is_wp_error( $moderation_result ) ) {
249                return $moderation_result;
250            }
251
252            $max_tokens = 480; // Default
253            $result     = $openai->request_chat_completion( $data, $max_tokens );
254
255            if ( is_wp_error( $result ) ) {
256                return $result;
257            }
258
259            $response = $result->choices[0]->message->content;
260
261            // In case of Jetpack we are setting a transient on the WPCOM and not the remote site. I think the 'get_current_user_id' may default for the connection owner at this point but we'll deal with this later.
262            set_transient( self::transient_name_for_completion(), $response, self::$text_completion_cooldown_seconds );
263            self::mark_post_as_ai_assisted( $post_id );
264            return $response;
265        }
266
267        $response = Client::wpcom_json_api_request_as_user(
268            sprintf( '/sites/%d/jetpack-ai/completions', $site_id ),
269            2,
270            array(
271                'method'  => 'post',
272                'headers' => array( 'content-type' => 'application/json' ),
273            ),
274            wp_json_encode(
275                array(
276                    'content' => $content,
277                ),
278                JSON_UNESCAPED_SLASHES
279            ),
280            'wpcom'
281        );
282
283        if ( is_wp_error( $response ) ) {
284            // If the user is not connected, return a more helpful error message.
285            if ( 'missing_token' === $response->get_error_code() ) {
286                return new WP_Error(
287                    'missing_token',
288                    $wp_disconnected_message,
289                    array( 'status' => 403 )
290                );
291            }
292            return $response;
293        }
294
295        $data = json_decode( wp_remote_retrieve_body( $response ) );
296
297        if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
298            return new WP_Error( $data->code, $data->message, $data->data );
299        }
300
301        // Do not cache if it should be skipped.
302        if ( ! $skip_cache ) {
303            set_transient( self::transient_name_for_completion(), $data, self::$text_completion_cooldown_seconds );
304        }
305        self::mark_post_as_ai_assisted( $post_id );
306
307        return $data;
308    }
309
310    /**
311     * Get an array of image objects back from WordPress.com based off a prompt.
312     *
313     * @param  string $prompt The prompt to generate images for.
314     * @param  int    $post_id Post ID for which the content is being generated.
315     * @return mixed
316     */
317    public static function get_dalle_generation( $prompt, $post_id ) {
318        $cache = get_transient( self::transient_name_for_image_generation( $prompt ) );
319        if ( $cache ) {
320            self::mark_post_as_ai_assisted( $post_id );
321            return $cache;
322        }
323
324        if ( ( new Status() )->is_offline_mode() ) {
325            return new WP_Error(
326                'dev_mode',
327                __( 'Jetpack AI is not available in offline mode.', 'jetpack' )
328            );
329        }
330
331        $wp_disconnected_message = __( 'Your account must be connected to WordPress.com to generate AI images. Please connect your account from the Jetpack settings screen to proceed.', 'jetpack' );
332
333        $site_id = Manager::get_site_id();
334        if ( is_wp_error( $site_id ) ) {
335            // If the site is not connected, return a more helpful error message.
336            if ( 'unavailable_site_id' === $site_id->get_error_code() ) {
337                return new WP_Error(
338                    'unavailable_site_id',
339                    $wp_disconnected_message,
340                    array( 'status' => 403 )
341                );
342            }
343            return $site_id;
344        }
345
346        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
347            if ( ! class_exists( 'OpenAI' ) ) {
348                \require_lib( 'openai' );
349            }
350
351            $result = ( new OpenAI( 'openai', array( 'post_id' => $post_id ) ) )->request_dalle_generation( $prompt );
352            if ( is_wp_error( $result ) ) {
353                return $result;
354            }
355            set_transient( self::transient_name_for_image_generation( $prompt ), $result, self::$image_generation_cache_timeout );
356            self::mark_post_as_ai_assisted( $post_id );
357            return $result;
358        }
359
360        $response = Client::wpcom_json_api_request_as_user(
361            sprintf( '/sites/%d/jetpack-ai/images/generations', $site_id ),
362            2,
363            array(
364                'method'  => 'post',
365                'headers' => array( 'content-type' => 'application/json' ),
366            ),
367            wp_json_encode(
368                array(
369                    'prompt' => $prompt,
370                ),
371                JSON_UNESCAPED_SLASHES
372            ),
373            'wpcom'
374        );
375
376        if ( is_wp_error( $response ) ) {
377            // If the user is not connected, return a more helpful error message.
378            if ( 'missing_token' === $response->get_error_code() ) {
379                return new WP_Error(
380                    'missing_token',
381                    $wp_disconnected_message,
382                    array( 'status' => 403 )
383                );
384            }
385            return $response;
386        }
387
388        $data = json_decode( wp_remote_retrieve_body( $response ) );
389
390        if ( wp_remote_retrieve_response_code( $response ) >= 400 ) {
391            return new WP_Error( $data->code, $data->message, $data->data );
392        }
393        set_transient( self::transient_name_for_image_generation( $prompt ), $data, self::$image_generation_cache_timeout );
394        self::mark_post_as_ai_assisted( $post_id );
395
396        return $data;
397    }
398
399    /**
400     * Get an object with useful data about the requests made to the AI.
401     *
402     * @return mixed
403     */
404    public static function get_ai_assistance_feature() {
405        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
406            // On WPCOM, we can get the ID from the site.
407            $blog_id                  = get_current_blog_id();
408            $has_ai_assistant_feature = \wpcom_site_has_feature( 'ai-assistant', $blog_id );
409
410            if ( ! class_exists( 'WPCOM\Jetpack_AI\Usage\Helper' ) ) {
411                if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php' ) ) {
412                    require_once WP_CONTENT_DIR . '/lib/jetpack-ai/usage/helper.php';
413                } else {
414                    return new WP_Error(
415                        'jetpack_ai_usage_helper_not_found',
416                        __( 'WPCOM\Jetpack_AI\Usage\Helper class not found.', 'jetpack' )
417                    );
418                }
419            }
420
421            if ( ! class_exists( 'WPCOM\Jetpack_AI\Feature_Control' ) ) {
422                if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php' ) ) {
423                    require_once WP_CONTENT_DIR . '/lib/jetpack-ai/feature-control.php';
424                } else {
425                    return new WP_Error(
426                        'jetpack_ai_feature_control_not_found',
427                        __( 'WPCOM\Jetpack_AI\Feature_Control class not found.', 'jetpack' )
428                    );
429                }
430            }
431
432            $chrome_ai_tokens = array();
433            if ( ! class_exists( 'WPCOM\Jetpack_AI\Chrome_AI_Tokens' ) ) {
434                if ( is_readable( WP_CONTENT_DIR . '/lib/jetpack-ai/chrome-ai-tokens.php' ) ) {
435                    require_once WP_CONTENT_DIR . '/lib/jetpack-ai/chrome-ai-tokens.php';
436                    $chrome_ai_tokens = WPCOM\Jetpack_AI\Chrome_AI_Tokens::get_tokens();
437                }
438            }
439
440            // Determine the upgrade type
441            $upgrade_type = wpcom_is_vip( $blog_id ) ? 'vip' : 'default';
442
443            return array(
444                'has-feature'          => $has_ai_assistant_feature,
445                'is-over-limit'        => WPCOM\Jetpack_AI\Usage\Helper::is_over_limit( $blog_id ),
446                'requests-count'       => WPCOM\Jetpack_AI\Usage\Helper::get_all_time_requests_count( $blog_id ),
447                'requests-limit'       => WPCOM\Jetpack_AI\Usage\Helper::get_free_requests_limit( $blog_id ),
448                'usage-period'         => WPCOM\Jetpack_AI\Usage\Helper::get_period_data( $blog_id ),
449                'site-require-upgrade' => WPCOM\Jetpack_AI\Usage\Helper::site_requires_upgrade( $blog_id ),
450                'upgrade-type'         => $upgrade_type,
451                'upgrade-url'          => WPCOM\Jetpack_AI\Usage\Helper::get_upgrade_url( $blog_id ),
452                'current-tier'         => WPCOM\Jetpack_AI\Usage\Helper::get_current_tier( $blog_id ),
453                'next-tier'            => WPCOM\Jetpack_AI\Usage\Helper::get_next_tier( $blog_id ),
454                'tier-plans'           => WPCOM\Jetpack_AI\Usage\Helper::get_tier_plans_list(),
455                'tier-plans-enabled'   => WPCOM\Jetpack_AI\Usage\Helper::ai_tier_plans_enabled(),
456                'costs'                => WPCOM\Jetpack_AI\Usage\Helper::get_costs(),
457                'features-control'     => WPCOM\Jetpack_AI\Feature_Control::get_features(),
458                'chrome-ai-tokens'     => $chrome_ai_tokens,
459            );
460        }
461
462        // Outside of WPCOM, we need to fetch the data from the site.
463        $blog_id = Jetpack_Options::get_option( 'id' );
464
465        // Try to pick the AI Assistant feature from cache.
466        $transient_name = self::transient_name_for_ai_assistance_feature( $blog_id );
467        $cache          = get_transient( $transient_name );
468        if ( $cache ) {
469            return $cache;
470        }
471
472        if ( null !== static::$ai_assistant_failed_request ) {
473            return static::$ai_assistant_failed_request;
474        }
475
476        $request_path = sprintf( '/sites/%d/jetpack-ai/ai-assistant-feature', $blog_id );
477
478        $wpcom_request = Client::wpcom_json_api_request_as_user(
479            $request_path,
480            'v2',
481            array(
482                'method'  => 'GET',
483                'headers' => array(
484                    'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
485                ),
486                'timeout' => 30,
487            ),
488            null,
489            'wpcom'
490        );
491
492        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
493        if ( 200 === $response_code ) {
494            $ai_assistant_feature_data = json_decode( wp_remote_retrieve_body( $wpcom_request ), true );
495
496            // Cache the AI Assistant feature, for Jetpack sites.
497            set_transient( $transient_name, $ai_assistant_feature_data, self::$ai_assistant_feature_cache_timeout );
498
499            return $ai_assistant_feature_data;
500        } else {
501            $error = new WP_Error(
502                'failed_to_fetch_data',
503                esc_html__( 'Unable to fetch the requested data.', 'jetpack' ),
504                array(
505                    'status' => $response_code,
506                    'ts'     => time(),
507                )
508            );
509
510            // Cache the AI Assistant feature error, for Jetpack sites, avoid API hammering.
511            set_transient( $transient_name, $error, self::$ai_assistant_feature_error_cache_timeout );
512
513            static::$ai_assistant_failed_request = $error;
514
515            return $error;
516        }
517    }
518}