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