PluginProbe
BeyondWords – AI audio for publishers / trunk
BeyondWords – AI audio for publishers vtrunk
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / api / class-client.php

class-client.php in BeyondWords – AI audio for publishers trunk, at src/api/class-client.php

837 lines 26.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * BeyondWords REST API client: one method per endpoint, errors normalised into post meta.
4 *
5 * Auth and Content-Type headers are injected via the `http_request_args`
6 * filter, only for requests targeting the BeyondWords API host.
7 *
8 * @package BeyondWords\Api
9 * @since 3.0.0
10 * @since 7.0.0 Moved from BeyondWords\Core\ApiClient to BeyondWords\Api\Client.
11 */
12
13 declare( strict_types = 1 );
14
15 namespace BeyondWords\Api;
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * BeyondWords API client.
21 *
22 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
23 */
24 class Client {
25
26 /**
27 * Format for the `beyondwords_error_message` post meta value.
28 *
29 * The HTTP-status prefix lets `Sync::update_or_recreate_audio()` recognise
30 * 404s by matching `#404:` without parsing the body.
31 */
32 const ERROR_FORMAT = '#%s: %s';
33
34 /**
35 * How long to cache editor dropdown data (languages, voices, templates, project settings).
36 */
37 const CACHE_TTL = 15 * MINUTE_IN_SECONDS;
38
39 /**
40 * How long to negative-cache a failed editor-dropdown fetch.
41 *
42 * Short enough that an outage self-heals within minutes, long enough that an
43 * unreachable API doesn't block every admin edit-screen render.
44 *
45 * @since 7.0.0
46 */
47 const CACHE_TTL_ON_ERROR = 2 * MINUTE_IN_SECONDS;
48
49 /**
50 * Default timeout, in seconds, for a BeyondWords API request.
51 *
52 * Kept short so synchronous save paths stay responsive; the slow endpoints
53 * get their own longer constants below.
54 *
55 * @since 7.0.0
56 */
57 const DEFAULT_REQUEST_TIMEOUT = 3;
58
59 /**
60 * Timeout, in seconds, for a content create.
61 *
62 * Creates have been observed exceeding the default 3s while the API still
63 * accepts the POST, stranding posts without a content ID. On VIP creates run
64 * async, so the longer wait never blocks a page load.
65 *
66 * @since 7.1.0
67 */
68 const CONTENT_REQUEST_TIMEOUT = 8;
69
70 /**
71 * Timeout, in seconds, for the post-create adoption probe.
72 *
73 * The probe only runs after a create outlived CONTENT_REQUEST_TIMEOUT, so
74 * healthy-path latency is the wrong yardstick — 2s buys a usable window
75 * against a degraded API while capping what it adds to a failing save.
76 *
77 * @since 7.1.0
78 */
79 const ADOPTION_PROBE_TIMEOUT = 2;
80
81 /**
82 * Timeout, in seconds, for the voices GET — the one slow endpoint.
83 *
84 * Voices is ~3.7s p95 against ~250ms for every other GET, so the default
85 * timeout would abandon (and then negative-cache) many cold-cache fetches.
86 *
87 * @since 7.0.0
88 */
89 const VOICES_REQUEST_TIMEOUT = 8;
90
91 /**
92 * API base URL, resolved once at bootstrap.
93 *
94 * @since 7.1.0
95 *
96 * @var string|null Null until `init()` runs.
97 */
98 private static ?string $api_url = null;
99
100 /**
101 * Register WordPress hooks.
102 *
103 * Must run early in the bootstrap so the filter precedes any API call.
104 */
105 public static function init(): void {
106 // WordPress fires http_request_args mid-upgrade, after the plugin files have
107 // been replaced, so resolve (and load) Urls now — a lazy autoload would fatal.
108 self::$api_url = \BeyondWords\Core\Urls::get_api_url();
109
110 // The VIP warning targets raised timeouts; this filter only adds headers.
111 // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args
112 add_filter( 'http_request_args', [ self::class, 'filter_http_request_args' ], 10, 2 );
113 }
114
115 /**
116 * Inject `X-Api-Key` and JSON `Content-Type` headers for BeyondWords API requests only.
117 *
118 * @param array<string,mixed> $args WordPress HTTP args.
119 * @param string $url Outbound request URL.
120 *
121 * @return array<string,mixed>
122 */
123 public static function filter_http_request_args( $args, $url ) {
124 if ( ! is_array( $args ) || ! is_string( $url ) ) {
125 return $args;
126 }
127
128 $api_url = (string) self::$api_url;
129
130 if ( '' === $api_url || ! str_starts_with( $url, $api_url ) ) {
131 return $args;
132 }
133
134 $headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : [];
135
136 // Caller-supplied X-Api-Key wins (lets tests inject deliberately bad keys).
137 if ( ! isset( $headers['X-Api-Key'] ) ) {
138 $headers['X-Api-Key'] = (string) get_option( 'beyondwords_api_key', '' );
139 }
140
141 $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
142
143 if (
144 in_array( $method, [ 'POST', 'PUT', 'DELETE' ], true )
145 && ! isset( $headers['Content-Type'] )
146 ) {
147 $headers['Content-Type'] = 'application/json';
148 }
149
150 $args['headers'] = $headers;
151
152 return $args;
153 }
154
155 /**
156 * GET /projects/:project/content/:content_id
157 *
158 * @return array<mixed>|\WP_Error|false Raw HTTP response, WP_Error on transport
159 * failure, or false when an ID is missing.
160 */
161 public static function get_content( int|string $content_id, int|string|null $project_id = null, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error|false {
162 if ( ! $project_id ) {
163 $project_id = get_option( 'beyondwords_project_id' );
164 }
165
166 if ( ! $project_id || ! $content_id ) {
167 return false;
168 }
169
170 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
171
172 return self::call_api( 'GET', $url, '', false, [], $timeout );
173 }
174
175 /**
176 * POST /projects/:project/content
177 *
178 * @param int $post_id WordPress post ID.
179 *
180 * @return array<mixed>|null|false Decoded response body — possibly an adopted
181 * existing record, see doc/source-id-race.md —
182 * null when the create failed, or false when
183 * the post has no project ID.
184 */
185 public static function create_audio( int $post_id ): array|null|false {
186 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
187
188 if ( ! $project_id ) {
189 return false;
190 }
191
192 $url = sprintf( '%s/projects/%d/content', \BeyondWords\Core\Urls::get_api_url(), $project_id );
193 $body = \BeyondWords\Post\Content::get_content_params( $post_id );
194 $response = self::call_api( 'POST', $url, $body, $post_id, [], self::CONTENT_REQUEST_TIMEOUT );
195
196 $existing = self::adopt_existing_content( $response, $post_id, $project_id );
197
198 if ( null !== $existing ) {
199 return $existing;
200 }
201
202 return json_decode( wp_remote_retrieve_body( $response ), true );
203 }
204
205 /**
206 * Recover content the create may already have written (422 race or transport loss).
207 *
208 * See doc/source-id-race.md.
209 *
210 * @since 7.0.0
211 *
212 * @param int $post_id WordPress post ID, which is also the content's source ID.
213 *
214 * @return array<mixed>|null Null when adoption does not apply, or the content
215 * couldn't be confirmed as this post's.
216 */
217 private static function adopt_existing_content( array|\WP_Error $response, int $post_id, int|string $project_id ): ?array {
218 $should_probe = is_wp_error( $response )
219 ? self::should_probe_after_transport_failure( $response )
220 : self::is_duplicate_source_id( $response );
221
222 if ( ! $should_probe ) {
223 return null;
224 }
225
226 $existing = self::get_content( $post_id, $project_id, self::ADOPTION_PROBE_TIMEOUT );
227
228 if ( is_wp_error( $existing ) ) {
229 // The API is unreachable; stop paying for a probe on every save.
230 set_transient( self::cache_key( 'adopt_probe_down' ), 1, self::CACHE_TTL_ON_ERROR );
231
232 return null;
233 }
234
235 if ( ! is_array( $existing ) ) {
236 return null;
237 }
238
239 $code = (int) wp_remote_retrieve_response_code( $existing );
240
241 if ( $code < 200 || $code > 299 ) {
242 return null;
243 }
244
245 $content = json_decode( wp_remote_retrieve_body( $existing ), true );
246
247 if ( ! is_array( $content ) || empty( $content['id'] ) ) {
248 return null;
249 }
250
251 // The lookup also resolves legacy numeric content IDs; require this post's source ID.
252 if ( (string) $post_id !== (string) ( $content['source_id'] ?? '' ) ) {
253 return null;
254 }
255
256 if ( ! self::is_this_sites_source_url( (string) ( $content['source_url'] ?? '' ) ) ) {
257 return null;
258 }
259
260 // The create only failed because the content already exists (or the reply was lost).
261 self::delete_errors( $post_id );
262
263 return $content;
264 }
265
266 /**
267 * Whether a transport-failed create warrants probing for an accepted record.
268 *
269 * @since 7.1.0
270 */
271 private static function should_probe_after_transport_failure( \WP_Error $error ): bool {
272 // The request never left WordPress, so nothing can have been accepted.
273 if ( 'http_request_not_executed' === $error->get_error_code() ) {
274 return false;
275 }
276
277 return ! get_transient( self::cache_key( 'adopt_probe_down' ) );
278 }
279
280 /**
281 * Whether a content record's source URL belongs to this install.
282 *
283 * Host and path-segment comparison; scheme and port are ignored so a site
284 * that moved from http to https still owns its pre-move content. A root-path
285 * install cannot be told apart from a subdirectory install on the same host
286 * by URL alone — see doc/source-id-race.md.
287 *
288 * @since 7.1.0
289 */
290 private static function is_this_sites_source_url( string $source_url ): bool {
291 $site = wp_parse_url( home_url() );
292 $source = wp_parse_url( $source_url );
293
294 $site_host = strtolower( (string) ( $site['host'] ?? '' ) );
295 $source_host = strtolower( (string) ( $source['host'] ?? '' ) );
296
297 if ( '' === $site_host || $site_host !== $source_host ) {
298 return false;
299 }
300
301 $site_path = trailingslashit( (string) ( $site['path'] ?? '/' ) );
302 $source_path = trailingslashit( (string) ( $source['path'] ?? '/' ) );
303
304 return str_starts_with( $source_path, $site_path );
305 }
306
307 /**
308 * Whether a create response is the API rejecting an already-used `source_id`.
309 *
310 * @since 7.0.0
311 */
312 private static function is_duplicate_source_id( array $response ): bool {
313 if ( 422 !== (int) wp_remote_retrieve_response_code( $response ) ) {
314 return false;
315 }
316
317 $body = json_decode( wp_remote_retrieve_body( $response ), true );
318
319 if ( ! is_array( $body ) || ! is_array( $body['errors'] ?? null ) ) {
320 return false;
321 }
322
323 foreach ( $body['errors'] as $error ) {
324 // Matched on `location`; the message beside it is free-form.
325 if ( is_array( $error ) && 'source_id' === ( $error['location'] ?? '' ) ) {
326 return true;
327 }
328 }
329
330 return false;
331 }
332
333 /**
334 * PUT /projects/:project/content/:content_id
335 *
336 * Falls back to the post ID as the content ID for Magic Embed posts that
337 * never had a BeyondWords-issued ID.
338 *
339 * @param int $post_id WordPress post ID.
340 *
341 * @return array<mixed>|null|false Decoded response body, or false when an ID is missing.
342 */
343 public static function update_audio( int $post_id ): array|null|false {
344 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
345 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
346
347 if ( ! $project_id || ! $content_id ) {
348 return false;
349 }
350
351 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
352 $body = \BeyondWords\Post\Content::get_content_params( $post_id );
353 $response = self::call_api( 'PUT', $url, $body, $post_id );
354
355 return json_decode( wp_remote_retrieve_body( $response ), true );
356 }
357
358 /**
359 * DELETE /projects/:project/content/:content_id
360 *
361 * @param int $post_id WordPress post ID.
362 *
363 * @return array<mixed>|null|false `false` when the request didn't return 204.
364 */
365 public static function delete_audio( int $post_id ): array|null|false {
366 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
367 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
368
369 return self::delete_audio_by_ids( $project_id, $content_id, $post_id );
370 }
371
372 /**
373 * DELETE /projects/:project/content/:content_id using explicit IDs.
374 *
375 * Split out from `delete_audio()` so the deferred trash/delete cron job can
376 * still delete after the post meta has been wiped.
377 *
378 * @since 7.0.0
379 *
380 * @param int|string|false $project_id BeyondWords project ID.
381 * @param int|string|false $content_id BeyondWords content ID.
382 * @param int|false $post_id Optional post ID for error attribution.
383 *
384 * @return array<mixed>|null|false `false` when an ID is missing or the request didn't return 204.
385 */
386 public static function delete_audio_by_ids( int|string|false $project_id, int|string|false $content_id, int|false $post_id = false ): array|null|false {
387 if ( ! $project_id || ! $content_id ) {
388 return false;
389 }
390
391 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
392 $response = self::call_api( 'DELETE', $url, '', $post_id );
393
394 if ( 204 !== wp_remote_retrieve_response_code( $response ) ) {
395 return false;
396 }
397
398 return json_decode( wp_remote_retrieve_body( $response ), true );
399 }
400
401 /**
402 * POST /projects/:project/content/batch_delete
403 *
404 * Refuses cross-project batches — the API only supports one project per request.
405 *
406 * @param int[] $post_ids WordPress post IDs.
407 *
408 * @return int[]|false Updated post IDs on success, empty array for non-OK responses.
409 *
410 * @throws \Exception When no posts have BeyondWords data, or multiple projects are mixed.
411 */
412 public static function batch_delete_audio( array $post_ids ): array|false {
413 $content_ids = [];
414 $updated_post_ids = [];
415
416 foreach ( $post_ids as $post_id ) {
417 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
418 if ( ! $project_id ) {
419 continue;
420 }
421
422 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id );
423 if ( ! $content_id ) {
424 continue;
425 }
426
427 $content_ids[ $project_id ][] = $content_id;
428 $updated_post_ids[] = $post_id;
429 }
430
431 if ( empty( $content_ids ) ) {
432 throw new \Exception(
433 esc_html__( 'None of the selected posts had valid BeyondWords audio data.', 'speechkit' )
434 );
435 }
436
437 if ( count( $content_ids ) > 1 ) {
438 throw new \Exception(
439 esc_html__( 'Batch delete can only be performed on audio belonging a single project.', 'speechkit' )
440 );
441 }
442
443 $project_id = array_key_first( $content_ids );
444 $url = sprintf( '%s/projects/%d/content/batch_delete', \BeyondWords\Core\Urls::get_api_url(), $project_id );
445 $body = (string) wp_json_encode( [ 'ids' => $content_ids[ $project_id ] ] );
446
447 $response = wp_remote_request( $url, self::build_args( 'POST', $body ) );
448
449 if ( is_wp_error( $response ) ) {
450 throw new \Exception( esc_html( $response->get_error_message() ) );
451 }
452
453 $response_code = wp_remote_retrieve_response_code( $response );
454
455 // On failure, return no IDs so the caller keeps local meta and can retry.
456 return $response_code <= 299 ? $updated_post_ids : [];
457 }
458
459 /**
460 * GET /projects/:project/player/by_source_id/:post_id
461 *
462 * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL.
463 *
464 * @param int $post_id WordPress post ID used as the source ID.
465 *
466 * @return array<mixed>|null|false
467 */
468 public static function get_player_by_source_id( int $post_id ): array|null|false {
469 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
470
471 if ( ! $project_id ) {
472 return false;
473 }
474
475 $url = sprintf( '%s/projects/%d/player/by_source_id/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id, $post_id );
476 $headers = [
477 'X-Import' => 'true',
478 'X-Referer' => esc_url( get_permalink( $post_id ) ),
479 ];
480
481 $response = self::call_api( 'GET', $url, '', $post_id, $headers );
482
483 return json_decode( wp_remote_retrieve_body( $response ), true );
484 }
485
486 /**
487 * GET /organization/languages
488 *
489 * @return array<mixed>|null|false
490 */
491 public static function get_languages(): array|null|false {
492 $url = sprintf( '%s/organization/languages', \BeyondWords\Core\Urls::get_api_url() );
493
494 return self::cached_get( 'languages', $url );
495 }
496
497 /**
498 * GET /organization/voices?filter[language.code]=…
499 *
500 * @param int|string $language_code BeyondWords language code (or numeric ID).
501 *
502 * @return array<mixed>|null|false
503 */
504 public static function get_voices( int|string $language_code ): array|null|false {
505 $url = sprintf(
506 '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary',
507 \BeyondWords\Core\Urls::get_api_url(),
508 rawurlencode( strval( $language_code ) )
509 );
510
511 return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT );
512 }
513
514 /**
515 * Look up one voice by ID by listing all voices for a language.
516 *
517 * The API doesn't expose `/voices/:id`, so we fetch the list and filter.
518 *
519 * @param int $voice_id Voice ID.
520 * @param int|string|false $language_code Language code (required — no global fallback as of 7.0.0).
521 *
522 * @return object|array<mixed>|false Voice record, or false when missing.
523 */
524 public static function get_voice( int $voice_id, int|string|false $language_code = false ): object|array|false {
525 if ( ! $language_code ) {
526 return false;
527 }
528
529 $voices = self::get_voices( $language_code );
530
531 if ( empty( $voices ) ) {
532 return false;
533 }
534
535 return array_column( $voices, null, 'id' )[ $voice_id ] ?? false;
536 }
537
538 /**
539 * GET /projects/:id/video_settings
540 *
541 * @param int|null $project_id Optional override; falls back to the global option.
542 *
543 * @return array<mixed>|null|false
544 */
545 public static function get_video_settings( ?int $project_id = null ): array|null|false {
546 if ( ! $project_id ) {
547 $project_id = get_option( 'beyondwords_project_id' );
548
549 if ( ! $project_id ) {
550 return false;
551 }
552 }
553
554 $url = sprintf( '%s/projects/%d/video_settings', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
555
556 return self::cached_get( 'video_settings_' . (int) $project_id, $url );
557 }
558
559 /**
560 * GET /projects/:id
561 *
562 * @since 7.0.0
563 *
564 * @param int|null $project_id Optional override; falls back to the global option.
565 *
566 * @return array<mixed>|null|false
567 */
568 public static function get_project( ?int $project_id = null ): array|null|false {
569 if ( ! $project_id ) {
570 $project_id = get_option( 'beyondwords_project_id' );
571
572 if ( ! $project_id ) {
573 return false;
574 }
575 }
576
577 $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
578
579 return self::cached_get( 'project_' . (int) $project_id, $url );
580 }
581
582 /**
583 * GET /summarization_settings_templates
584 *
585 * @since 7.0.0
586 *
587 * @return array<mixed>|null|false
588 */
589 public static function get_summarization_settings_templates(): array|null|false {
590 $url = sprintf( '%s/summarization_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
591
592 return self::cached_get( 'summarization_settings_templates', $url );
593 }
594
595 /**
596 * GET /video_settings_templates
597 *
598 * @since 7.0.0
599 *
600 * @return array<mixed>|null|false
601 */
602 public static function get_video_settings_templates(): array|null|false {
603 $url = sprintf( '%s/video_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
604
605 return self::cached_get( 'video_settings_templates', $url );
606 }
607
608 /**
609 * Make the API call, normalising errors into post meta when a post is supplied.
610 *
611 * A 401 also clears `beyondwords_valid_api_connection` so the settings page
612 * re-runs validation.
613 *
614 * @param string $method HTTP method.
615 * @param string $url Absolute URL.
616 * @param string $body Request body (already JSON-encoded for write methods).
617 * @param int|false $post_id WordPress post ID for error attribution; false to suppress.
618 * @param array<string,string> $headers Extra per-request headers.
619 * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT.
620 */
621 public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error {
622 $post = get_post( $post_id );
623
624 self::delete_errors( $post_id );
625
626 $response = wp_remote_request( $url, self::build_args( $method, $body, $headers, $timeout ) );
627
628 $response_code = (int) wp_remote_retrieve_response_code( $response );
629
630 if ( 401 === $response_code ) {
631 delete_option( 'beyondwords_valid_api_connection' );
632 // Drop the recent-check cache too, so the settings page revalidates immediately.
633 delete_transient( \BeyondWords\Settings\Utils::CONNECTION_CHECK_TRANSIENT );
634 }
635
636 if (
637 $post instanceof \WP_Post
638 && \BeyondWords\Settings\Fields::INTEGRATION_REST_API === \BeyondWords\Settings\Fields::get_integration_method( $post )
639 && ( is_wp_error( $response ) || $response_code > 299 )
640 ) {
641 $message = self::error_message_from_response( $response );
642 self::save_error_message( $post_id, $message, $response_code );
643 }
644
645 return $response;
646 }
647
648 /**
649 * Build the WordPress HTTP args for a BeyondWords API call.
650 *
651 * Auth and Content-Type headers are added by `filter_http_request_args()`,
652 * not here, so they also apply to third-party calls against the API.
653 *
654 * @param string $method HTTP method.
655 * @param string $body Request body.
656 * @param array<string,string> $headers Extra per-request headers.
657 * @param int $timeout Request timeout in seconds.
658 *
659 * @return array<string,mixed>
660 */
661 private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array {
662 return [
663 'blocking' => true,
664 'body' => $body,
665 'headers' => $headers,
666 'method' => strtoupper( $method ),
667 'timeout' => $timeout,
668 ];
669 }
670
671 /**
672 * Build a transient key for a cached GET.
673 *
674 * Salted with the project ID + API key so changing either invalidates
675 * implicitly — no flush needed, which object-cache hosts can't do anyway.
676 *
677 * @since 7.0.0
678 *
679 * @param string $suffix Endpoint-specific key suffix.
680 */
681 private static function cache_key( string $suffix ): string {
682 $salt = substr(
683 md5( (string) get_option( 'beyondwords_project_id', '' ) . '|' . (string) get_option( 'beyondwords_api_key', '' ) ),
684 0,
685 12
686 );
687
688 return 'beyondwords_api_' . $suffix . '_' . $salt;
689 }
690
691 /**
692 * GET an editor-render-path endpoint, caching both hits and failures.
693 *
694 * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so
695 * an unreachable API is probed at most once per interval, not every render.
696 *
697 * @since 7.0.0
698 *
699 * @param string $suffix Cache-key suffix (include any project/language id).
700 * @param string $url Absolute endpoint URL.
701 * @param int $timeout Request timeout in seconds.
702 *
703 * @return array<mixed>|null|false Decoded body on the fetching call; the cached
704 * value ([] after a cached failure) thereafter.
705 */
706 private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false {
707 $key = self::cache_key( $suffix );
708 $cached = get_transient( $key );
709
710 if ( false !== $cached ) {
711 return $cached;
712 }
713
714 $response = self::call_api( 'GET', $url, '', false, [], $timeout );
715 $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
716
717 if (
718 ! is_wp_error( $response )
719 && wp_remote_retrieve_response_code( $response ) < 300
720 && is_array( $decoded )
721 ) {
722 set_transient( $key, $decoded, self::CACHE_TTL );
723
724 return $decoded;
725 }
726
727 set_transient( $key, [], self::CACHE_TTL_ON_ERROR );
728
729 return $decoded;
730 }
731
732 /**
733 * Pull a human-readable error string out of a BeyondWords API response.
734 *
735 * BeyondWords returns errors in two shapes — `errors[]` (validation) and
736 * `message` (other) — so we check both and fall back to the HTTP status text.
737 */
738 public static function error_message_from_response( array|\WP_Error $response ): string {
739 if ( is_wp_error( $response ) ) {
740 $detail = $response->get_error_message();
741 // Filters can attach non-string messages; coerce so the `: string` return holds.
742 $detail = is_string( $detail ) ? $detail : (string) wp_json_encode( $detail );
743
744 // An empty detail falls through to save_error_message()'s generic fallback.
745 if ( '' === $detail ) {
746 return '';
747 }
748
749 return sprintf(
750 /* translators: %1$s is replaced with the support email link, %2$s with the transport error detail. */
751 esc_html__( 'API request error. Please contact %1$s. (%2$s)', 'speechkit' ),
752 '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>',
753 $detail
754 );
755 }
756
757 $body = json_decode( wp_remote_retrieve_body( $response ), true );
758 $message = wp_remote_retrieve_response_message( $response );
759
760 if ( is_array( $body ) ) {
761 if ( array_key_exists( 'errors', $body ) ) {
762 $messages = [];
763 foreach ( $body['errors'] as $error ) {
764 $messages[] = implode( ' ', array_values( $error ) );
765 }
766 $message = implode( ', ', $messages );
767 } elseif ( array_key_exists( 'message', $body ) ) {
768 // `message` is arbitrary JSON; coerce so the `: string` return
769 // type holds under strict_types.
770 $message = is_string( $body['message'] )
771 ? $body['message']
772 : (string) wp_json_encode( $body['message'] );
773 }
774 }
775
776 return $message;
777 }
778
779 /**
780 * Clear any error meta keys for a post.
781 *
782 * @param int|false $post_id WordPress post ID; false is a no-op.
783 */
784 public static function delete_errors( int|false $post_id ): void {
785 if ( ! $post_id ) {
786 return;
787 }
788
789 delete_post_meta( $post_id, 'speechkit_error_message' );
790 delete_post_meta( $post_id, 'beyondwords_error_message' );
791 }
792
793 /**
794 * Persist an error message to a post for surfacing in the editor.
795 *
796 * Skipped for Magic Embed 404s because client-side fetches retry on
797 * subsequent visits — surfacing a 404 here would be misleading.
798 *
799 * @param int|false $post_id WordPress post ID; false is a no-op.
800 * @param string $message Error message.
801 * @param int|string $code HTTP status (or string code).
802 */
803 public static function save_error_message( int|false $post_id, string $message = '', int|string $code = 500 ): void {
804 if ( ! $post_id ) {
805 return;
806 }
807
808 $post = get_post( $post_id );
809
810 if (
811 404 === $code
812 && $post instanceof \WP_Post
813 && \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === \BeyondWords\Settings\Fields::get_integration_method( $post )
814 ) {
815 return;
816 }
817
818 if ( ! $message ) {
819 $message = sprintf(
820 /* translators: %s is replaced with the support email link */
821 esc_html__( 'API request error. Please contact %s.', 'speechkit' ),
822 '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
823 );
824 }
825
826 if ( ! $code ) {
827 $code = 500;
828 }
829
830 update_post_meta(
831 $post_id,
832 'beyondwords_error_message',
833 sprintf( self::ERROR_FORMAT, (string) $code, $message )
834 );
835 }
836 }
837