PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
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 7.0.0, at src/api/class-client.php

725 lines 22.4 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 * VIP's approved ceiling for a blocking remote request; content writes return
53 * immediately and generate audio server-side, so nothing needs longer.
54 *
55 * @since 7.0.0
56 */
57 const DEFAULT_REQUEST_TIMEOUT = 3;
58
59 /**
60 * Timeout, in seconds, for the voices GET — the one slow endpoint.
61 *
62 * Voices is ~3.7s p95 against ~250ms for every other GET, so the default
63 * timeout would abandon (and then negative-cache) many cold-cache fetches.
64 *
65 * @since 7.0.0
66 */
67 const VOICES_REQUEST_TIMEOUT = 8;
68
69 /**
70 * Register WordPress hooks.
71 *
72 * Must run early in the bootstrap so the filter precedes any API call.
73 */
74 public static function init(): void {
75 // The VIP warning targets raised timeouts; this filter only adds headers.
76 // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args
77 add_filter( 'http_request_args', [ self::class, 'filter_http_request_args' ], 10, 2 );
78 }
79
80 /**
81 * Inject `X-Api-Key` and JSON `Content-Type` headers for BeyondWords API requests only.
82 *
83 * @param array<string,mixed> $args WordPress HTTP args.
84 * @param string $url Outbound request URL.
85 *
86 * @return array<string,mixed>
87 */
88 public static function filter_http_request_args( $args, $url ) {
89 if ( ! is_array( $args ) || ! is_string( $url ) ) {
90 return $args;
91 }
92
93 $api_url = \BeyondWords\Core\Urls::get_api_url();
94
95 if ( '' === $api_url || ! str_starts_with( $url, $api_url ) ) {
96 return $args;
97 }
98
99 $headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : [];
100
101 // Caller-supplied X-Api-Key wins (lets tests inject deliberately bad keys).
102 if ( ! isset( $headers['X-Api-Key'] ) ) {
103 $headers['X-Api-Key'] = (string) get_option( 'beyondwords_api_key', '' );
104 }
105
106 $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
107
108 if (
109 in_array( $method, [ 'POST', 'PUT', 'DELETE' ], true )
110 && ! isset( $headers['Content-Type'] )
111 ) {
112 $headers['Content-Type'] = 'application/json';
113 }
114
115 $args['headers'] = $headers;
116
117 return $args;
118 }
119
120 /**
121 * GET /projects/:project/content/:content_id
122 *
123 * @param string $content_id BeyondWords content ID.
124 * @param int|string|null $project_id Optional project ID override.
125 *
126 * @return array<mixed>|\WP_Error|false Raw HTTP response, WP_Error on transport
127 * failure, or false when an ID is missing.
128 */
129 public static function get_content( int|string $content_id, int|string|null $project_id = null ): array|\WP_Error|false {
130 if ( ! $project_id ) {
131 $project_id = get_option( 'beyondwords_project_id' );
132 }
133
134 if ( ! $project_id || ! $content_id ) {
135 return false;
136 }
137
138 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
139
140 return self::call_api( 'GET', $url );
141 }
142
143 /**
144 * POST /projects/:project/content
145 *
146 * @param int $post_id WordPress post ID.
147 *
148 * @return array<mixed>|null|false Decoded response body, or false when the
149 * post has no project ID.
150 */
151 public static function create_audio( int $post_id ): array|null|false {
152 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
153
154 if ( ! $project_id ) {
155 return false;
156 }
157
158 $url = sprintf( '%s/projects/%d/content', \BeyondWords\Core\Urls::get_api_url(), $project_id );
159 $body = \BeyondWords\Post\Content::get_content_params( $post_id );
160 $response = self::call_api( 'POST', $url, $body, $post_id );
161
162 $existing = self::adopt_existing_content( $response, $post_id, $project_id );
163
164 if ( null !== $existing ) {
165 return $existing;
166 }
167
168 return json_decode( wp_remote_retrieve_body( $response ), true );
169 }
170
171 /**
172 * Recover the content record a duplicate-`source_id` create collided with.
173 *
174 * See doc/source-id-race.md.
175 *
176 * @since 7.0.0
177 *
178 * @param int $post_id WordPress post ID, which is also the content's source ID.
179 *
180 * @return array<mixed>|null Null when the create didn't collide, or the content
181 * couldn't be confirmed as this site's.
182 */
183 private static function adopt_existing_content( array|\WP_Error $response, int $post_id, int|string $project_id ): ?array {
184 if ( ! self::is_duplicate_source_id( $response ) ) {
185 return null;
186 }
187
188 $existing = self::get_content( $post_id, $project_id );
189
190 if ( ! is_array( $existing ) || wp_remote_retrieve_response_code( $existing ) > 299 ) {
191 return null;
192 }
193
194 $content = json_decode( wp_remote_retrieve_body( $existing ), true );
195
196 if ( ! is_array( $content ) || empty( $content['id'] ) ) {
197 return null;
198 }
199
200 // Source IDs are bare post IDs, so a second install on this project collides.
201 $site_root = (string) preg_replace( '#^https?://#', '', trailingslashit( home_url() ) );
202 $source_url = (string) preg_replace( '#^https?://#', '', (string) ( $content['source_url'] ?? '' ) );
203
204 // Scheme-insensitive: an http to https move leaves the old scheme stored.
205 if ( '' === $site_root || ! str_starts_with( $source_url, $site_root ) ) {
206 return null;
207 }
208
209 // The create only failed because the content already exists.
210 self::delete_errors( $post_id );
211
212 return $content;
213 }
214
215 /**
216 * Whether a create response is the API rejecting an already-used `source_id`.
217 *
218 * @since 7.0.0
219 */
220 private static function is_duplicate_source_id( array|\WP_Error $response ): bool {
221 if ( 422 !== (int) wp_remote_retrieve_response_code( $response ) ) {
222 return false;
223 }
224
225 $body = json_decode( wp_remote_retrieve_body( $response ), true );
226
227 if ( ! is_array( $body ) || ! is_array( $body['errors'] ?? null ) ) {
228 return false;
229 }
230
231 foreach ( $body['errors'] as $error ) {
232 // Matched on `location`; the message beside it is free-form.
233 if ( is_array( $error ) && 'source_id' === ( $error['location'] ?? '' ) ) {
234 return true;
235 }
236 }
237
238 return false;
239 }
240
241 /**
242 * PUT /projects/:project/content/:content_id
243 *
244 * Falls back to the post ID as the content ID for Magic Embed posts that
245 * never had a BeyondWords-issued ID.
246 *
247 * @param int $post_id WordPress post ID.
248 *
249 * @return array<mixed>|null|false Decoded response body, or false when an ID is missing.
250 */
251 public static function update_audio( int $post_id ): array|null|false {
252 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
253 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
254
255 if ( ! $project_id || ! $content_id ) {
256 return false;
257 }
258
259 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
260 $body = \BeyondWords\Post\Content::get_content_params( $post_id );
261 $response = self::call_api( 'PUT', $url, $body, $post_id );
262
263 return json_decode( wp_remote_retrieve_body( $response ), true );
264 }
265
266 /**
267 * DELETE /projects/:project/content/:content_id
268 *
269 * @param int $post_id WordPress post ID.
270 *
271 * @return array<mixed>|null|false `false` when the request didn't return 204.
272 */
273 public static function delete_audio( int $post_id ): array|null|false {
274 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
275 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
276
277 return self::delete_audio_by_ids( $project_id, $content_id, $post_id );
278 }
279
280 /**
281 * DELETE /projects/:project/content/:content_id using explicit IDs.
282 *
283 * Split out from `delete_audio()` so the deferred trash/delete cron job can
284 * still delete after the post meta has been wiped.
285 *
286 * @since 7.0.0
287 *
288 * @param int|string|false $project_id BeyondWords project ID.
289 * @param int|string|false $content_id BeyondWords content ID.
290 * @param int|false $post_id Optional post ID for error attribution.
291 *
292 * @return array<mixed>|null|false `false` when an ID is missing or the request didn't return 204.
293 */
294 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 {
295 if ( ! $project_id || ! $content_id ) {
296 return false;
297 }
298
299 $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
300 $response = self::call_api( 'DELETE', $url, '', $post_id );
301
302 if ( 204 !== wp_remote_retrieve_response_code( $response ) ) {
303 return false;
304 }
305
306 return json_decode( wp_remote_retrieve_body( $response ), true );
307 }
308
309 /**
310 * POST /projects/:project/content/batch_delete
311 *
312 * Refuses cross-project batches — the API only supports one project per request.
313 *
314 * @param int[] $post_ids WordPress post IDs.
315 *
316 * @return int[]|false Updated post IDs on success, empty array for non-OK responses.
317 *
318 * @throws \Exception When no posts have BeyondWords data, or multiple projects are mixed.
319 */
320 public static function batch_delete_audio( array $post_ids ): array|false {
321 $content_ids = [];
322 $updated_post_ids = [];
323
324 foreach ( $post_ids as $post_id ) {
325 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
326 if ( ! $project_id ) {
327 continue;
328 }
329
330 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id );
331 if ( ! $content_id ) {
332 continue;
333 }
334
335 $content_ids[ $project_id ][] = $content_id;
336 $updated_post_ids[] = $post_id;
337 }
338
339 if ( empty( $content_ids ) ) {
340 throw new \Exception(
341 esc_html__( 'None of the selected posts had valid BeyondWords audio data.', 'speechkit' )
342 );
343 }
344
345 if ( count( $content_ids ) > 1 ) {
346 throw new \Exception(
347 esc_html__( 'Batch delete can only be performed on audio belonging a single project.', 'speechkit' )
348 );
349 }
350
351 $project_id = array_key_first( $content_ids );
352 $url = sprintf( '%s/projects/%d/content/batch_delete', \BeyondWords\Core\Urls::get_api_url(), $project_id );
353 $body = (string) wp_json_encode( [ 'ids' => $content_ids[ $project_id ] ] );
354
355 $response = wp_remote_request( $url, self::build_args( 'POST', $body ) );
356
357 if ( is_wp_error( $response ) ) {
358 throw new \Exception( esc_html( $response->get_error_message() ) );
359 }
360
361 $response_code = wp_remote_retrieve_response_code( $response );
362
363 // On failure, return no IDs so the caller keeps local meta and can retry.
364 return $response_code <= 299 ? $updated_post_ids : [];
365 }
366
367 /**
368 * GET /projects/:project/player/by_source_id/:post_id
369 *
370 * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL.
371 *
372 * @param int $post_id WordPress post ID used as the source ID.
373 *
374 * @return array<mixed>|null|false
375 */
376 public static function get_player_by_source_id( int $post_id ): array|null|false {
377 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
378
379 if ( ! $project_id ) {
380 return false;
381 }
382
383 $url = sprintf( '%s/projects/%d/player/by_source_id/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id, $post_id );
384 $headers = [
385 'X-Import' => 'true',
386 'X-Referer' => esc_url( get_permalink( $post_id ) ),
387 ];
388
389 $response = self::call_api( 'GET', $url, '', $post_id, $headers );
390
391 return json_decode( wp_remote_retrieve_body( $response ), true );
392 }
393
394 /**
395 * GET /organization/languages
396 *
397 * @return array<mixed>|null|false
398 */
399 public static function get_languages(): array|null|false {
400 $url = sprintf( '%s/organization/languages', \BeyondWords\Core\Urls::get_api_url() );
401
402 return self::cached_get( 'languages', $url );
403 }
404
405 /**
406 * GET /organization/voices?filter[language.code]=…
407 *
408 * @param int|string $language_code BeyondWords language code (or numeric ID).
409 *
410 * @return array<mixed>|null|false
411 */
412 public static function get_voices( int|string $language_code ): array|null|false {
413 $url = sprintf(
414 '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary',
415 \BeyondWords\Core\Urls::get_api_url(),
416 rawurlencode( strval( $language_code ) )
417 );
418
419 return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT );
420 }
421
422 /**
423 * Look up one voice by ID by listing all voices for a language.
424 *
425 * The API doesn't expose `/voices/:id`, so we fetch the list and filter.
426 *
427 * @param int $voice_id Voice ID.
428 * @param int|string|false $language_code Language code (required — no global fallback as of 7.0.0).
429 *
430 * @return object|array<mixed>|false Voice record, or false when missing.
431 */
432 public static function get_voice( int $voice_id, int|string|false $language_code = false ): object|array|false {
433 if ( ! $language_code ) {
434 return false;
435 }
436
437 $voices = self::get_voices( $language_code );
438
439 if ( empty( $voices ) ) {
440 return false;
441 }
442
443 return array_column( $voices, null, 'id' )[ $voice_id ] ?? false;
444 }
445
446 /**
447 * GET /projects/:id/video_settings
448 *
449 * @param int|null $project_id Optional override; falls back to the global option.
450 *
451 * @return array<mixed>|null|false
452 */
453 public static function get_video_settings( ?int $project_id = null ): array|null|false {
454 if ( ! $project_id ) {
455 $project_id = get_option( 'beyondwords_project_id' );
456
457 if ( ! $project_id ) {
458 return false;
459 }
460 }
461
462 $url = sprintf( '%s/projects/%d/video_settings', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
463
464 return self::cached_get( 'video_settings_' . (int) $project_id, $url );
465 }
466
467 /**
468 * GET /projects/:id
469 *
470 * @since 7.0.0
471 *
472 * @param int|null $project_id Optional override; falls back to the global option.
473 *
474 * @return array<mixed>|null|false
475 */
476 public static function get_project( ?int $project_id = null ): array|null|false {
477 if ( ! $project_id ) {
478 $project_id = get_option( 'beyondwords_project_id' );
479
480 if ( ! $project_id ) {
481 return false;
482 }
483 }
484
485 $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
486
487 return self::cached_get( 'project_' . (int) $project_id, $url );
488 }
489
490 /**
491 * GET /summarization_settings_templates
492 *
493 * @since 7.0.0
494 *
495 * @return array<mixed>|null|false
496 */
497 public static function get_summarization_settings_templates(): array|null|false {
498 $url = sprintf( '%s/summarization_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
499
500 return self::cached_get( 'summarization_settings_templates', $url );
501 }
502
503 /**
504 * GET /video_settings_templates
505 *
506 * @since 7.0.0
507 *
508 * @return array<mixed>|null|false
509 */
510 public static function get_video_settings_templates(): array|null|false {
511 $url = sprintf( '%s/video_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
512
513 return self::cached_get( 'video_settings_templates', $url );
514 }
515
516 /**
517 * Make the API call, normalising errors into post meta when a post is supplied.
518 *
519 * A 401 also clears `beyondwords_valid_api_connection` so the settings page
520 * re-runs validation.
521 *
522 * @param string $method HTTP method.
523 * @param string $url Absolute URL.
524 * @param string $body Request body (already JSON-encoded for write methods).
525 * @param int|false $post_id WordPress post ID for error attribution; false to suppress.
526 * @param array<string,string> $headers Extra per-request headers.
527 * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT.
528 */
529 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 {
530 $post = get_post( $post_id );
531
532 self::delete_errors( $post_id );
533
534 $response = wp_remote_request( $url, self::build_args( $method, $body, $headers, $timeout ) );
535
536 $response_code = wp_remote_retrieve_response_code( $response );
537
538 if ( 401 === $response_code ) {
539 delete_option( 'beyondwords_valid_api_connection' );
540 }
541
542 if (
543 $post instanceof \WP_Post
544 && \BeyondWords\Settings\Fields::INTEGRATION_REST_API === \BeyondWords\Settings\Fields::get_integration_method( $post )
545 && ( is_wp_error( $response ) || $response_code > 299 )
546 ) {
547 $message = self::error_message_from_response( $response );
548 self::save_error_message( $post_id, $message, $response_code );
549 }
550
551 return $response;
552 }
553
554 /**
555 * Build the WordPress HTTP args for a BeyondWords API call.
556 *
557 * Auth and Content-Type headers are added by `filter_http_request_args()`,
558 * not here, so they also apply to third-party calls against the API.
559 *
560 * @param string $method HTTP method.
561 * @param string $body Request body.
562 * @param array<string,string> $headers Extra per-request headers.
563 * @param int $timeout Request timeout in seconds.
564 *
565 * @return array<string,mixed>
566 */
567 private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array {
568 return [
569 'blocking' => true,
570 'body' => $body,
571 'headers' => $headers,
572 'method' => strtoupper( $method ),
573 'timeout' => $timeout,
574 ];
575 }
576
577 /**
578 * Build a transient key for a cached GET.
579 *
580 * Salted with the project ID + API key so changing either invalidates
581 * implicitly — no flush needed, which object-cache hosts can't do anyway.
582 *
583 * @since 7.0.0
584 *
585 * @param string $suffix Endpoint-specific key suffix.
586 */
587 private static function cache_key( string $suffix ): string {
588 $salt = substr(
589 md5( (string) get_option( 'beyondwords_project_id', '' ) . '|' . (string) get_option( 'beyondwords_api_key', '' ) ),
590 0,
591 12
592 );
593
594 return 'beyondwords_api_' . $suffix . '_' . $salt;
595 }
596
597 /**
598 * GET an editor-render-path endpoint, caching both hits and failures.
599 *
600 * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so
601 * an unreachable API is probed at most once per interval, not every render.
602 *
603 * @since 7.0.0
604 *
605 * @param string $suffix Cache-key suffix (include any project/language id).
606 * @param string $url Absolute endpoint URL.
607 * @param int $timeout Request timeout in seconds.
608 *
609 * @return array<mixed>|null|false Decoded body on the fetching call; the cached
610 * value ([] after a cached failure) thereafter.
611 */
612 private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false {
613 $key = self::cache_key( $suffix );
614 $cached = get_transient( $key );
615
616 if ( false !== $cached ) {
617 return $cached;
618 }
619
620 $response = self::call_api( 'GET', $url, '', false, [], $timeout );
621 $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
622
623 if (
624 ! is_wp_error( $response )
625 && wp_remote_retrieve_response_code( $response ) < 300
626 && is_array( $decoded )
627 ) {
628 set_transient( $key, $decoded, self::CACHE_TTL );
629
630 return $decoded;
631 }
632
633 set_transient( $key, [], self::CACHE_TTL_ON_ERROR );
634
635 return $decoded;
636 }
637
638 /**
639 * Pull a human-readable error string out of a BeyondWords API response.
640 *
641 * BeyondWords returns errors in two shapes — `errors[]` (validation) and
642 * `message` (other) — so we check both and fall back to the HTTP status text.
643 */
644 public static function error_message_from_response( array|\WP_Error $response ): string {
645 $body = json_decode( wp_remote_retrieve_body( $response ), true );
646 $message = wp_remote_retrieve_response_message( $response );
647
648 if ( is_array( $body ) ) {
649 if ( array_key_exists( 'errors', $body ) ) {
650 $messages = [];
651 foreach ( $body['errors'] as $error ) {
652 $messages[] = implode( ' ', array_values( $error ) );
653 }
654 $message = implode( ', ', $messages );
655 } elseif ( array_key_exists( 'message', $body ) ) {
656 // `message` is arbitrary JSON; coerce so the `: string` return
657 // type holds under strict_types.
658 $message = is_string( $body['message'] )
659 ? $body['message']
660 : (string) wp_json_encode( $body['message'] );
661 }
662 }
663
664 return $message;
665 }
666
667 /**
668 * Clear any error meta keys for a post.
669 *
670 * @param int|false $post_id WordPress post ID; false is a no-op.
671 */
672 public static function delete_errors( int|false $post_id ): void {
673 if ( ! $post_id ) {
674 return;
675 }
676
677 delete_post_meta( $post_id, 'speechkit_error_message' );
678 delete_post_meta( $post_id, 'beyondwords_error_message' );
679 }
680
681 /**
682 * Persist an error message to a post for surfacing in the editor.
683 *
684 * Skipped for Magic Embed 404s because client-side fetches retry on
685 * subsequent visits — surfacing a 404 here would be misleading.
686 *
687 * @param int|false $post_id WordPress post ID; false is a no-op.
688 * @param string $message Error message.
689 * @param int|string $code HTTP status (or string code).
690 */
691 public static function save_error_message( int|false $post_id, string $message = '', int|string $code = 500 ): void {
692 if ( ! $post_id ) {
693 return;
694 }
695
696 $post = get_post( $post_id );
697
698 if (
699 404 === $code
700 && $post instanceof \WP_Post
701 && \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === \BeyondWords\Settings\Fields::get_integration_method( $post )
702 ) {
703 return;
704 }
705
706 if ( ! $message ) {
707 $message = sprintf(
708 /* translators: %s is replaced with the support email link */
709 esc_html__( 'API request error. Please contact %s.', 'speechkit' ),
710 '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
711 );
712 }
713
714 if ( ! $code ) {
715 $code = 500;
716 }
717
718 update_post_meta(
719 $post_id,
720 'beyondwords_error_message',
721 sprintf( self::ERROR_FORMAT, (string) $code, $message )
722 );
723 }
724 }
725