PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | _inc/lib/class-jetpack-ai-helper.php +529 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,529 @@
1 +<?php
2 +/**
3 + * API helper for the AI blocks.
4 + *
5 + * @package automattic/jetpack
6 + * @since 11.8
7 + */
8 +
9 +use Automattic\Jetpack\Connection\Client;
10 +use Automattic\Jetpack\Connection\Manager;
11 +use Automattic\Jetpack\Search\Plan as Search_Plan;
12 +use Automattic\Jetpack\Status;
13 +use 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).
18 +require_once __DIR__ . '/class-jetpack-ai-settings.php';
19 +
20 +/**
21 + * Class Jetpack_AI_Helper
22 + *
23 + * @since 11.8
24 + */
25 +class 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 +}