| 1 |
<?php |
| 2 |
|
| 3 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 4 |
|
| 5 |
namespace Yoast\WP\SEO\AI\Authentication\Application; |
| 6 |
|
| 7 |
use WP_User; |
| 8 |
use Yoast\WP\SEO\AI\Authentication\Domain\Exceptions\Auth_Strategy_Unavailable_Exception; |
| 9 |
use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Outline_Parameters; |
| 10 |
use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Suggestion_Parameters; |
| 11 |
use Yoast\WP\SEO\AI\Generator\Domain\Suggestions_Parameters; |
| 12 |
use Yoast\WP\SEO\AI\Generator\Domain\Usage_Parameters; |
| 13 |
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception; |
| 14 |
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception; |
| 15 |
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception; |
| 16 |
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request; |
| 17 |
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Response; |
| 18 |
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface; |
| 19 |
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait; |
| 20 |
use YoastSEO_Vendor\Psr\Log\NullLogger; |
| 21 |
|
| 22 |
/** |
| 23 |
* Sends an authenticated AI request using a primary strategy, with an optional fallback. |
| 24 |
*/ |
| 25 |
class AI_Request_Sender implements LoggerAwareInterface { |
| 26 |
|
| 27 |
use LoggerAwareTrait; |
| 28 |
|
| 29 |
/** |
| 30 |
* The primary strategy. |
| 31 |
* |
| 32 |
* @var Auth_Strategy_Interface |
| 33 |
*/ |
| 34 |
private $primary; |
| 35 |
|
| 36 |
/** |
| 37 |
* The fallback strategy, or null when no fallback should be tried on persistent failure. |
| 38 |
* |
| 39 |
* @var Auth_Strategy_Interface|null |
| 40 |
*/ |
| 41 |
private $fallback; |
| 42 |
|
| 43 |
/** |
| 44 |
* Constructor. |
| 45 |
* |
| 46 |
* @param Auth_Strategy_Interface $primary The primary strategy. |
| 47 |
* @param Auth_Strategy_Interface|null $fallback The fallback strategy, or null for no fallback. |
| 48 |
*/ |
| 49 |
public function __construct( Auth_Strategy_Interface $primary, ?Auth_Strategy_Interface $fallback = null ) { |
| 50 |
$this->primary = $primary; |
| 51 |
$this->fallback = $fallback; |
| 52 |
$this->logger = new NullLogger(); |
| 53 |
} |
| 54 |
|
| 55 |
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Strategies throw typed exceptions that propagate out. |
| 56 |
|
| 57 |
/** |
| 58 |
* Requests a content outline from the AI service. |
| 59 |
* |
| 60 |
* @param Content_Outline_Parameters $parameters The outline parameters. |
| 61 |
* |
| 62 |
* @return Response The parsed response. |
| 63 |
*/ |
| 64 |
public function get_content_outline_suggestions( Content_Outline_Parameters $parameters ): Response { |
| 65 |
$request = new Request( |
| 66 |
'/content-planner/next-post-outline', |
| 67 |
[ |
| 68 |
'subject' => [ |
| 69 |
'language' => $parameters->get_language(), |
| 70 |
'content' => $parameters->get_content(), |
| 71 |
], |
| 72 |
], |
| 73 |
[ 'X-Yst-Cohort' => $parameters->get_editor() ], |
| 74 |
); |
| 75 |
|
| 76 |
return $this->send( $request, $parameters->get_user() ); |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* Requests next-post content suggestions from the AI service. |
| 81 |
* |
| 82 |
* @param Content_Suggestion_Parameters $parameters The suggestion parameters. |
| 83 |
* |
| 84 |
* @return Response The parsed response. |
| 85 |
*/ |
| 86 |
public function get_content_suggestions( Content_Suggestion_Parameters $parameters ): Response { |
| 87 |
$request = new Request( |
| 88 |
'/content-planner/next-post-suggestions', |
| 89 |
[ |
| 90 |
'subject' => [ |
| 91 |
'language' => $parameters->get_language(), |
| 92 |
'content' => $parameters->get_content(), |
| 93 |
], |
| 94 |
], |
| 95 |
[ 'X-Yst-Cohort' => $parameters->get_editor() ], |
| 96 |
); |
| 97 |
|
| 98 |
return $this->send( $request, $parameters->get_user() ); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Requests suggestions for the given suggestion type. |
| 103 |
* |
| 104 |
* @param Suggestions_Parameters $parameters The suggestions parameters. |
| 105 |
* |
| 106 |
* @return Response The parsed response. |
| 107 |
*/ |
| 108 |
public function get_suggestions( Suggestions_Parameters $parameters ): Response { |
| 109 |
$user = $parameters->get_user(); |
| 110 |
$request = new Request( |
| 111 |
'/openai/suggestions/' . $parameters->get_suggestion_type(), |
| 112 |
[ |
| 113 |
'service' => 'openai', |
| 114 |
'user_id' => (string) $user->ID, |
| 115 |
'subject' => [ |
| 116 |
'content' => $parameters->get_prompt_content(), |
| 117 |
'focus_keyphrase' => $parameters->get_focus_keyphrase(), |
| 118 |
'language' => $parameters->get_language(), |
| 119 |
'platform' => $parameters->get_platform(), |
| 120 |
], |
| 121 |
], |
| 122 |
[ 'X-Yst-Cohort' => $parameters->get_editor() ], |
| 123 |
); |
| 124 |
|
| 125 |
return $this->send( $request, $user ); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Requests the user's current usage. |
| 130 |
* |
| 131 |
* @param Usage_Parameters $parameters The usage parameters. |
| 132 |
* |
| 133 |
* @return Response The parsed response. |
| 134 |
*/ |
| 135 |
public function get_usage( Usage_Parameters $parameters ): Response { |
| 136 |
$action_path = $parameters->is_free() ? '/usage/free-usages' : '/usage/' . $parameters->get_period(); |
| 137 |
$request = new Request( $action_path, [], [], Request::METHOD_GET ); |
| 138 |
|
| 139 |
return $this->send( $request, $parameters->get_user() ); |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Records the user's consent on the AI service. |
| 144 |
* |
| 145 |
* The strategy identifies the WP user to the service (the OAuth path injects `user_id` into the |
| 146 |
* POST body), so no body is built here. |
| 147 |
* |
| 148 |
* @param WP_User $user The WP user granting consent. |
| 149 |
* |
| 150 |
* @return Response The parsed response. |
| 151 |
*/ |
| 152 |
public function grant_consent( WP_User $user ): Response { |
| 153 |
return $this->send( new Request( '/user/consent', [], [], Request::METHOD_POST ), $user ); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Revokes the user's consent on the AI service via `DELETE /user/consent`. |
| 158 |
* |
| 159 |
* The strategy identifies the WP user to the service (the OAuth path appends the `user_id` |
| 160 |
* query parameter to the DELETE), so no body is built here. Note the legacy Token path may |
| 161 |
* provision a fresh JWT to authenticate the DELETE — Consent_Handler::revoke_consent() |
| 162 |
* invalidates any locally stored JWTs afterwards, so credentials never outlive consent. |
| 163 |
* |
| 164 |
* @param WP_User $user The WP user revoking consent. |
| 165 |
* |
| 166 |
* @return Response The parsed response. |
| 167 |
*/ |
| 168 |
public function revoke_consent( WP_User $user ): Response { |
| 169 |
return $this->send( new Request( '/user/consent', [], [], Request::METHOD_DELETE ), $user ); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Sends an authenticated AI request, falling back to the secondary strategy on persistent failure. |
| 174 |
* |
| 175 |
* The fallback is only tried for failures that mean the primary strategy could not authenticate |
| 176 |
* or reach the service; every authoritative answer the service gave (a content-filter rejection, |
| 177 |
* a missing license, a consent or scope 403, a rate limit, a server error) propagates to the |
| 178 |
* caller instead — see {@see self::is_fallback_eligible()}. |
| 179 |
* |
| 180 |
* Kept public for backward compatibility — callers that have a pre-built Request may dispatch it |
| 181 |
* directly. New call sites should prefer the named methods above. |
| 182 |
* |
| 183 |
* @param Request $request The base request, without auth headers. |
| 184 |
* @param WP_User $user The WP user the request is on behalf of. |
| 185 |
* |
| 186 |
* @return Response The parsed response. |
| 187 |
*/ |
| 188 |
public function send( Request $request, WP_User $user ): Response { |
| 189 |
try { |
| 190 |
return $this->primary->send( $request, $user ); |
| 191 |
} |
| 192 |
catch ( Remote_Request_Exception $exception ) { |
| 193 |
if ( ! $this->is_fallback_eligible( $exception ) ) { |
| 194 |
$this->logger->warning( |
| 195 |
'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); the failure is not recoverable by the fallback, propagating to the caller.', |
| 196 |
$this->error_context( $exception ), |
| 197 |
); |
| 198 |
throw $exception; |
| 199 |
} |
| 200 |
if ( $this->fallback === null ) { |
| 201 |
$this->logger->warning( |
| 202 |
'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); no fallback configured, giving up.', |
| 203 |
$this->error_context( $exception ), |
| 204 |
); |
| 205 |
throw $exception; |
| 206 |
} |
| 207 |
$this->logger->warning( |
| 208 |
'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); falling back to the secondary strategy.', |
| 209 |
$this->error_context( $exception ), |
| 210 |
); |
| 211 |
|
| 212 |
try { |
| 213 |
$response = $this->fallback->send( $request, $user ); |
| 214 |
} |
| 215 |
catch ( Remote_Request_Exception $fallback_exception ) { |
| 216 |
$this->logger->warning( |
| 217 |
'Secondary AI auth strategy also failed ({error_id}, HTTP {status}: {message}); giving up.', |
| 218 |
$this->error_context( $fallback_exception ), |
| 219 |
); |
| 220 |
throw $fallback_exception; |
| 221 |
} |
| 222 |
|
| 223 |
$this->logger->debug( 'Secondary AI auth strategy succeeded after primary failure.' ); |
| 224 |
return $response; |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Decides whether a primary-strategy failure may be retried via the fallback strategy. |
| 230 |
* |
| 231 |
* The fallback exists to recover from a primary strategy that could not *authenticate* or *reach* |
| 232 |
* the service. It must not fire on an authoritative answer the service gave about the request, the |
| 233 |
* user, or the account, because the fallback talks to the same service for the same user and would |
| 234 |
* either get the same answer or, worse, fail for an unrelated reason and mask the real one — for |
| 235 |
* example a 400 content-filter rejection (profanity) would be silently turned into a repeat request. |
| 236 |
* |
| 237 |
* Only three failures clear this gate, all of them about the primary strategy itself rather than |
| 238 |
* the service's verdict: {@see Auth_Strategy_Unavailable_Exception} (no OAuth site token could be |
| 239 |
* acquired), {@see Unauthorized_Exception} (a 401 — the token is missing, invalid, or expired), and |
| 240 |
* {@see WP_Request_Exception} (a transport failure reaching the service, the very case OAuth exists |
| 241 |
* to survive — note it carries status 400 by convention but is a transport failure, not a service |
| 242 |
* answer). Every other {@see Remote_Request_Exception} — a service-issued 400 |
| 243 |
* ({@see \Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception}, e.g. a content |
| 244 |
* filter), 402, 403 (consent, scope, or any other forbidden), 404, 408, 429, 500, 503 — is an |
| 245 |
* authoritative answer that propagates untouched. |
| 246 |
* |
| 247 |
* @param Remote_Request_Exception $exception The failure thrown by the primary strategy. |
| 248 |
* |
| 249 |
* @return bool True when the fallback may be tried, false when the failure must propagate. |
| 250 |
*/ |
| 251 |
private function is_fallback_eligible( Remote_Request_Exception $exception ): bool { |
| 252 |
return $exception instanceof Auth_Strategy_Unavailable_Exception |
| 253 |
|| $exception instanceof Unauthorized_Exception |
| 254 |
|| $exception instanceof WP_Request_Exception; |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Builds the PSR-3 log context for a failed remote request, defaulting a missing error identifier |
| 259 |
* to `unknown` so the rendered message never has an empty slot. |
| 260 |
* |
| 261 |
* @param Remote_Request_Exception $exception The failed request exception. |
| 262 |
* |
| 263 |
* @return array<string, int|string> The log context: error_id, status, message. |
| 264 |
*/ |
| 265 |
private function error_context( Remote_Request_Exception $exception ): array { |
| 266 |
$error_id = $exception->get_error_identifier(); |
| 267 |
if ( $error_id === '' ) { |
| 268 |
$error_id = 'unknown'; |
| 269 |
} |
| 270 |
|
| 271 |
return [ |
| 272 |
'error_id' => $error_id, |
| 273 |
'status' => $exception->getCode(), |
| 274 |
'message' => $exception->getMessage(), |
| 275 |
]; |
| 276 |
} |
| 277 |
|
| 278 |
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.Missing |
| 279 |
} |
| 280 |
|