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