$args WordPress HTTP args. * @param string $url Outbound request URL. * * @return array */ public static function filter_http_request_args( $args, $url ) { if ( ! is_array( $args ) || ! is_string( $url ) ) { return $args; } $api_url = \BeyondWords\Core\Urls::get_api_url(); if ( '' === $api_url || ! str_starts_with( $url, $api_url ) ) { return $args; } $headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : []; // Caller-supplied X-Api-Key wins (lets tests inject deliberately bad keys). if ( ! isset( $headers['X-Api-Key'] ) ) { $headers['X-Api-Key'] = (string) get_option( 'beyondwords_api_key', '' ); } $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) ); if ( in_array( $method, [ 'POST', 'PUT', 'DELETE' ], true ) && ! isset( $headers['Content-Type'] ) ) { $headers['Content-Type'] = 'application/json'; } $args['headers'] = $headers; return $args; } /** * GET /projects/:project/content/:content_id * * @param string $content_id BeyondWords content ID. * @param int|string|null $project_id Optional project ID override. * * @return array|\WP_Error|false Raw HTTP response, WP_Error on transport * failure, or false when an ID is missing. */ public static function get_content( int|string $content_id, int|string|null $project_id = null ): array|\WP_Error|false { if ( ! $project_id ) { $project_id = get_option( 'beyondwords_project_id' ); } if ( ! $project_id || ! $content_id ) { return false; } $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) ); return self::call_api( 'GET', $url ); } /** * POST /projects/:project/content * * @param int $post_id WordPress post ID. * * @return array|null|false Decoded response body, or false when the * post has no project ID. */ public static function create_audio( int $post_id ): array|null|false { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); if ( ! $project_id ) { return false; } $url = sprintf( '%s/projects/%d/content', \BeyondWords\Core\Urls::get_api_url(), $project_id ); $body = \BeyondWords\Post\Content::get_content_params( $post_id ); $response = self::call_api( 'POST', $url, $body, $post_id ); $existing = self::adopt_existing_content( $response, $post_id, $project_id ); if ( null !== $existing ) { return $existing; } return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * Recover the content record a duplicate-`source_id` create collided with. * * See doc/source-id-race.md. * * @since 7.0.0 * * @param int $post_id WordPress post ID, which is also the content's source ID. * * @return array|null Null when the create didn't collide, or the content * couldn't be confirmed as this site's. */ private static function adopt_existing_content( array|\WP_Error $response, int $post_id, int|string $project_id ): ?array { if ( ! self::is_duplicate_source_id( $response ) ) { return null; } $existing = self::get_content( $post_id, $project_id ); if ( ! is_array( $existing ) || wp_remote_retrieve_response_code( $existing ) > 299 ) { return null; } $content = json_decode( wp_remote_retrieve_body( $existing ), true ); if ( ! is_array( $content ) || empty( $content['id'] ) ) { return null; } // Source IDs are bare post IDs, so a second install on this project collides. $site_root = (string) preg_replace( '#^https?://#', '', trailingslashit( home_url() ) ); $source_url = (string) preg_replace( '#^https?://#', '', (string) ( $content['source_url'] ?? '' ) ); // Scheme-insensitive: an http to https move leaves the old scheme stored. if ( '' === $site_root || ! str_starts_with( $source_url, $site_root ) ) { return null; } // The create only failed because the content already exists. self::delete_errors( $post_id ); return $content; } /** * Whether a create response is the API rejecting an already-used `source_id`. * * @since 7.0.0 */ private static function is_duplicate_source_id( array|\WP_Error $response ): bool { if ( 422 !== (int) wp_remote_retrieve_response_code( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || ! is_array( $body['errors'] ?? null ) ) { return false; } foreach ( $body['errors'] as $error ) { // Matched on `location`; the message beside it is free-form. if ( is_array( $error ) && 'source_id' === ( $error['location'] ?? '' ) ) { return true; } } return false; } /** * PUT /projects/:project/content/:content_id * * Falls back to the post ID as the content ID for Magic Embed posts that * never had a BeyondWords-issued ID. * * @param int $post_id WordPress post ID. * * @return array|null|false Decoded response body, or false when an ID is missing. */ public static function update_audio( int $post_id ): array|null|false { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true ); if ( ! $project_id || ! $content_id ) { return false; } $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) ); $body = \BeyondWords\Post\Content::get_content_params( $post_id ); $response = self::call_api( 'PUT', $url, $body, $post_id ); return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * DELETE /projects/:project/content/:content_id * * @param int $post_id WordPress post ID. * * @return array|null|false `false` when the request didn't return 204. */ public static function delete_audio( int $post_id ): array|null|false { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true ); return self::delete_audio_by_ids( $project_id, $content_id, $post_id ); } /** * DELETE /projects/:project/content/:content_id using explicit IDs. * * Split out from `delete_audio()` so the deferred trash/delete cron job can * still delete after the post meta has been wiped. * * @since 7.0.0 * * @param int|string|false $project_id BeyondWords project ID. * @param int|string|false $content_id BeyondWords content ID. * @param int|false $post_id Optional post ID for error attribution. * * @return array|null|false `false` when an ID is missing or the request didn't return 204. */ 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 { if ( ! $project_id || ! $content_id ) { return false; } $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) ); $response = self::call_api( 'DELETE', $url, '', $post_id ); if ( 204 !== wp_remote_retrieve_response_code( $response ) ) { return false; } return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * POST /projects/:project/content/batch_delete * * Refuses cross-project batches — the API only supports one project per request. * * @param int[] $post_ids WordPress post IDs. * * @return int[]|false Updated post IDs on success, empty array for non-OK responses. * * @throws \Exception When no posts have BeyondWords data, or multiple projects are mixed. */ public static function batch_delete_audio( array $post_ids ): array|false { $content_ids = []; $updated_post_ids = []; foreach ( $post_ids as $post_id ) { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); if ( ! $project_id ) { continue; } $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id ); if ( ! $content_id ) { continue; } $content_ids[ $project_id ][] = $content_id; $updated_post_ids[] = $post_id; } if ( empty( $content_ids ) ) { throw new \Exception( esc_html__( 'None of the selected posts had valid BeyondWords audio data.', 'speechkit' ) ); } if ( count( $content_ids ) > 1 ) { throw new \Exception( esc_html__( 'Batch delete can only be performed on audio belonging a single project.', 'speechkit' ) ); } $project_id = array_key_first( $content_ids ); $url = sprintf( '%s/projects/%d/content/batch_delete', \BeyondWords\Core\Urls::get_api_url(), $project_id ); $body = (string) wp_json_encode( [ 'ids' => $content_ids[ $project_id ] ] ); $response = wp_remote_request( $url, self::build_args( 'POST', $body ) ); if ( is_wp_error( $response ) ) { throw new \Exception( esc_html( $response->get_error_message() ) ); } $response_code = wp_remote_retrieve_response_code( $response ); // On failure, return no IDs so the caller keeps local meta and can retry. return $response_code <= 299 ? $updated_post_ids : []; } /** * GET /projects/:project/player/by_source_id/:post_id * * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL. * * @param int $post_id WordPress post ID used as the source ID. * * @return array|null|false */ public static function get_player_by_source_id( int $post_id ): array|null|false { $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id ); if ( ! $project_id ) { return false; } $url = sprintf( '%s/projects/%d/player/by_source_id/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id, $post_id ); $headers = [ 'X-Import' => 'true', 'X-Referer' => esc_url( get_permalink( $post_id ) ), ]; $response = self::call_api( 'GET', $url, '', $post_id, $headers ); return json_decode( wp_remote_retrieve_body( $response ), true ); } /** * GET /organization/languages * * @return array|null|false */ public static function get_languages(): array|null|false { $url = sprintf( '%s/organization/languages', \BeyondWords\Core\Urls::get_api_url() ); return self::cached_get( 'languages', $url ); } /** * GET /organization/voices?filter[language.code]=… * * @param int|string $language_code BeyondWords language code (or numeric ID). * * @return array|null|false */ public static function get_voices( int|string $language_code ): array|null|false { $url = sprintf( '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary', \BeyondWords\Core\Urls::get_api_url(), rawurlencode( strval( $language_code ) ) ); return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT ); } /** * Look up one voice by ID by listing all voices for a language. * * The API doesn't expose `/voices/:id`, so we fetch the list and filter. * * @param int $voice_id Voice ID. * @param int|string|false $language_code Language code (required — no global fallback as of 7.0.0). * * @return object|array|false Voice record, or false when missing. */ public static function get_voice( int $voice_id, int|string|false $language_code = false ): object|array|false { if ( ! $language_code ) { return false; } $voices = self::get_voices( $language_code ); if ( empty( $voices ) ) { return false; } return array_column( $voices, null, 'id' )[ $voice_id ] ?? false; } /** * GET /projects/:id/video_settings * * @param int|null $project_id Optional override; falls back to the global option. * * @return array|null|false */ public static function get_video_settings( ?int $project_id = null ): array|null|false { if ( ! $project_id ) { $project_id = get_option( 'beyondwords_project_id' ); if ( ! $project_id ) { return false; } } $url = sprintf( '%s/projects/%d/video_settings', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id ); return self::cached_get( 'video_settings_' . (int) $project_id, $url ); } /** * GET /projects/:id * * @since 7.0.0 * * @param int|null $project_id Optional override; falls back to the global option. * * @return array|null|false */ public static function get_project( ?int $project_id = null ): array|null|false { if ( ! $project_id ) { $project_id = get_option( 'beyondwords_project_id' ); if ( ! $project_id ) { return false; } } $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id ); return self::cached_get( 'project_' . (int) $project_id, $url ); } /** * GET /summarization_settings_templates * * @since 7.0.0 * * @return array|null|false */ public static function get_summarization_settings_templates(): array|null|false { $url = sprintf( '%s/summarization_settings_templates', \BeyondWords\Core\Urls::get_api_url() ); return self::cached_get( 'summarization_settings_templates', $url ); } /** * GET /video_settings_templates * * @since 7.0.0 * * @return array|null|false */ public static function get_video_settings_templates(): array|null|false { $url = sprintf( '%s/video_settings_templates', \BeyondWords\Core\Urls::get_api_url() ); return self::cached_get( 'video_settings_templates', $url ); } /** * Make the API call, normalising errors into post meta when a post is supplied. * * A 401 also clears `beyondwords_valid_api_connection` so the settings page * re-runs validation. * * @param string $method HTTP method. * @param string $url Absolute URL. * @param string $body Request body (already JSON-encoded for write methods). * @param int|false $post_id WordPress post ID for error attribution; false to suppress. * @param array $headers Extra per-request headers. * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT. */ 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 { $post = get_post( $post_id ); self::delete_errors( $post_id ); $response = wp_remote_request( $url, self::build_args( $method, $body, $headers, $timeout ) ); $response_code = wp_remote_retrieve_response_code( $response ); if ( 401 === $response_code ) { delete_option( 'beyondwords_valid_api_connection' ); } if ( $post instanceof \WP_Post && \BeyondWords\Settings\Fields::INTEGRATION_REST_API === \BeyondWords\Settings\Fields::get_integration_method( $post ) && ( is_wp_error( $response ) || $response_code > 299 ) ) { $message = self::error_message_from_response( $response ); self::save_error_message( $post_id, $message, $response_code ); } return $response; } /** * Build the WordPress HTTP args for a BeyondWords API call. * * Auth and Content-Type headers are added by `filter_http_request_args()`, * not here, so they also apply to third-party calls against the API. * * @param string $method HTTP method. * @param string $body Request body. * @param array $headers Extra per-request headers. * @param int $timeout Request timeout in seconds. * * @return array */ private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array { return [ 'blocking' => true, 'body' => $body, 'headers' => $headers, 'method' => strtoupper( $method ), 'timeout' => $timeout, ]; } /** * Build a transient key for a cached GET. * * Salted with the project ID + API key so changing either invalidates * implicitly — no flush needed, which object-cache hosts can't do anyway. * * @since 7.0.0 * * @param string $suffix Endpoint-specific key suffix. */ private static function cache_key( string $suffix ): string { $salt = substr( md5( (string) get_option( 'beyondwords_project_id', '' ) . '|' . (string) get_option( 'beyondwords_api_key', '' ) ), 0, 12 ); return 'beyondwords_api_' . $suffix . '_' . $salt; } /** * GET an editor-render-path endpoint, caching both hits and failures. * * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so * an unreachable API is probed at most once per interval, not every render. * * @since 7.0.0 * * @param string $suffix Cache-key suffix (include any project/language id). * @param string $url Absolute endpoint URL. * @param int $timeout Request timeout in seconds. * * @return array|null|false Decoded body on the fetching call; the cached * value ([] after a cached failure) thereafter. */ private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false { $key = self::cache_key( $suffix ); $cached = get_transient( $key ); if ( false !== $cached ) { return $cached; } $response = self::call_api( 'GET', $url, '', false, [], $timeout ); $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) < 300 && is_array( $decoded ) ) { set_transient( $key, $decoded, self::CACHE_TTL ); return $decoded; } set_transient( $key, [], self::CACHE_TTL_ON_ERROR ); return $decoded; } /** * Pull a human-readable error string out of a BeyondWords API response. * * BeyondWords returns errors in two shapes — `errors[]` (validation) and * `message` (other) — so we check both and fall back to the HTTP status text. */ public static function error_message_from_response( array|\WP_Error $response ): string { $body = json_decode( wp_remote_retrieve_body( $response ), true ); $message = wp_remote_retrieve_response_message( $response ); if ( is_array( $body ) ) { if ( array_key_exists( 'errors', $body ) ) { $messages = []; foreach ( $body['errors'] as $error ) { $messages[] = implode( ' ', array_values( $error ) ); } $message = implode( ', ', $messages ); } elseif ( array_key_exists( 'message', $body ) ) { // `message` is arbitrary JSON; coerce so the `: string` return // type holds under strict_types. $message = is_string( $body['message'] ) ? $body['message'] : (string) wp_json_encode( $body['message'] ); } } return $message; } /** * Clear any error meta keys for a post. * * @param int|false $post_id WordPress post ID; false is a no-op. */ public static function delete_errors( int|false $post_id ): void { if ( ! $post_id ) { return; } delete_post_meta( $post_id, 'speechkit_error_message' ); delete_post_meta( $post_id, 'beyondwords_error_message' ); } /** * Persist an error message to a post for surfacing in the editor. * * Skipped for Magic Embed 404s because client-side fetches retry on * subsequent visits — surfacing a 404 here would be misleading. * * @param int|false $post_id WordPress post ID; false is a no-op. * @param string $message Error message. * @param int|string $code HTTP status (or string code). */ public static function save_error_message( int|false $post_id, string $message = '', int|string $code = 500 ): void { if ( ! $post_id ) { return; } $post = get_post( $post_id ); if ( 404 === $code && $post instanceof \WP_Post && \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === \BeyondWords\Settings\Fields::get_integration_method( $post ) ) { return; } if ( ! $message ) { $message = sprintf( /* translators: %s is replaced with the support email link */ esc_html__( 'API request error. Please contact %s.', 'speechkit' ), 'support@beyondwords.io' ); } if ( ! $code ) { $code = 500; } update_post_meta( $post_id, 'beyondwords_error_message', sprintf( self::ERROR_FORMAT, (string) $code, $message ) ); } }