PluginProbe
BeyondWords – AI audio for publishers / 6.2.0
BeyondWords – AI audio for publishers v6.2.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 / Core / ApiClient.php

ApiClient.php in BeyondWords – AI audio for publishers 6.2.0, at src/Core/ApiClient.php

674 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Beyondwords\Wordpress\Core;
6
7 use Beyondwords\Wordpress\Core\Environment;
8 use Beyondwords\Wordpress\Core\Request;
9 use Beyondwords\Wordpress\Component\Post\PostContentUtils;
10 use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
11 use Beyondwords\Wordpress\Component\Settings\Fields\IntegrationMethod\IntegrationMethod;
12
13 /**
14 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
15 **/
16 defined('ABSPATH') || exit;
17
18 class ApiClient
19 {
20 /**
21 * Error format.
22 *
23 * The error format used to display error messages in WordPress admin.
24 *
25 * @var string
26 */
27 public const ERROR_FORMAT = '#%s: %s';
28
29 /**
30 * GET /projects/:id/content/:id.
31 *
32 * @param string $contentId BeyomndWords Content ID
33 * @param int|string $projectId BeyondWords Project ID, optional.
34 *
35 * @return WP_Response|false
36 **/
37 public static function getContent(int|string $contentId, int|string|null $projectId = null): array|false
38 {
39 if (! $projectId) {
40 $projectId = get_option('beyondwords_project_id');
41 }
42
43 if (! $projectId || ! $contentId) {
44 return false;
45 }
46
47 $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId);
48
49 $request = new Request('GET', $url);
50
51 return self::callApi($request);
52 }
53
54 /**
55 * POST /projects/:id/content.
56 *
57 * @since 3.0.0
58 * @since 5.2.0 Make static.
59 *
60 * @param int $postId WordPress Post ID
61 *
62 * @return mixed JSON-decoded response body
63 **/
64 public static function createAudio(int $postId): array|null|false
65 {
66 $projectId = PostMetaUtils::getProjectId($postId);
67
68 if (! $projectId) {
69 return false;
70 }
71
72 $url = sprintf('%s/projects/%d/content', Environment::getApiUrl(), $projectId);
73
74 $body = PostContentUtils::getContentParams($postId);
75
76 $request = new Request('POST', $url, $body);
77 $response = self::callApi($request, $postId);
78
79 return json_decode(wp_remote_retrieve_body($response), true);
80 }
81
82 /**
83 * PUT /projects/:id/content/:id.
84 *
85 * @since 3.0.0
86 * @since 5.2.0 Make static.
87 * @since 6.0.0 Add support for Magic Embed.
88 *
89 * @param int $postId WordPress Post ID
90 *
91 * @return mixed JSON-decoded response body
92 **/
93 public static function updateAudio(int $postId): array|null|false
94 {
95 $projectId = PostMetaUtils::getProjectId($postId);
96 $contentId = PostMetaUtils::getContentId($postId, true); // fallback to Post ID
97
98 if (! $projectId || ! $contentId) {
99 return false;
100 }
101
102 $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId);
103
104 $body = PostContentUtils::getContentParams($postId);
105
106 $request = new Request('PUT', $url, $body);
107 $response = self::callApi($request, $postId);
108
109 return json_decode(wp_remote_retrieve_body($response), true);
110 }
111
112 /**
113 * DELETE /projects/:id/content/:id.
114 *
115 * @since 3.0.0
116 * @since 5.2.0 Make static.
117 * @since 6.0.0 Add support for Magic Embed.
118 *
119 * @param int $postId WordPress Post ID
120 *
121 * @return mixed JSON-decoded response body
122 **/
123 public static function deleteAudio(int $postId): array|null|false
124 {
125 $projectId = PostMetaUtils::getProjectId($postId);
126 $contentId = PostMetaUtils::getContentId($postId, true); // fallback to Post ID
127
128 if (! $projectId || ! $contentId) {
129 return false;
130 }
131
132 $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId);
133
134 $request = new Request('DELETE', $url);
135 $response = self::callApi($request, $postId);
136 $code = wp_remote_retrieve_response_code($response);
137
138 // Expect 204 Deleted
139 if ($code !== 204) {
140 return false;
141 }
142
143 return json_decode(wp_remote_retrieve_body($response), true);
144 }
145
146 /**
147 * DELETE /projects/:id/content/:id.
148 *
149 * @since 4.1.0
150 * @since 5.2.0 Make static.
151 * @since 5.2.2 Remove sslverify param & increase timeout to 30s for REST API calls.
152 *
153 * @param int[] $postIds Array of WordPress Post IDs.
154 *
155 * @throws \Exception
156 * @return mixed JSON-decoded response body
157 **/
158 public static function batchDeleteAudio(array $postIds): array|false
159 {
160 $contentIds = [];
161 $updatedPostIds = [];
162
163 foreach ($postIds as $postId) {
164 $projectId = PostMetaUtils::getProjectId($postId);
165
166 if (! $projectId) {
167 continue;
168 }
169
170 $contentId = PostMetaUtils::getContentId($postId);
171
172 if (! $contentId) {
173 continue;
174 }
175
176 $contentIds[$projectId][] = $contentId;
177 $updatedPostIds[] = $postId;
178 }
179
180 if (! count($contentIds)) {
181 throw new \Exception(esc_html__('None of the selected posts had valid BeyondWords audio data.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong
182 }
183
184 if (count($contentIds) > 1) {
185 throw new \Exception(esc_html__('Batch delete can only be performed on audio belonging a single project.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong
186 }
187
188 $projectId = array_key_first($contentIds);
189
190 $url = sprintf('%s/projects/%d/content/batch_delete', Environment::getApiUrl(), $projectId);
191
192 $body = (string) wp_json_encode(['ids' => $contentIds[$projectId]]);
193
194 $request = new Request('POST', $url, $body);
195
196 $args = [
197 'blocking' => true,
198 'body' => $request->getBody(),
199 'headers' => $request->getHeaders(),
200 'method' => $request->getMethod(),
201 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
202 ];
203
204 $response = wp_remote_request($request->getUrl(), $args);
205
206 // WordPress error performing API call
207 if (is_wp_error($response)) {
208 throw new \Exception(esc_html($response->get_error_message()));
209 }
210
211 $responseCode = wp_remote_retrieve_response_code($response);
212
213 if ($responseCode <= 299) {
214 // An OK response means all content IDs in the request were deleted
215 return $updatedPostIds;
216 } else {
217 // For non-OK responses we do not want to delete any custom fields,
218 // so return an empty array
219 return [];
220 }
221 }
222
223 /**
224 * GET /projects/:id/player/by_source_id/:id.
225 *
226 * This will return the player data for a post by its source ID. It is used
227 * for Client-Side integration, where the content is generated based on the
228 * source ID & URL of the post instead of a BeyondWords REST API call.
229 *
230 * @since 6.0.0 Introduced.
231 *
232 * @param int $postId WordPress Post ID
233 *
234 * @return mixed JSON-decoded response body, or false on failure.
235 **/
236 public static function getPlayerBySourceId(int $postId): array|null|false
237 {
238 $projectId = PostMetaUtils::getProjectId($postId);
239
240 if (! $projectId) {
241 return false;
242 }
243
244 $url = sprintf('%s/projects/%d/player/by_source_id/%d', Environment::getApiUrl(), $projectId, $postId);
245
246 $request = new Request('GET', $url);
247 $request->addHeaders([
248 'X-Import' => 'true',
249 'X-Referer' => esc_url(get_permalink($postId)),
250 ]);
251
252 $response = self::callApi($request, $postId);
253
254 return json_decode(wp_remote_retrieve_body($response), true);
255 }
256
257 /**
258 * GET /organization/languages
259 *
260 * @since 4.0.0 Introduced
261 * @since 4.0.2 Prefix endpoint with /organization
262 * @since 5.0.0 Cache response using transients
263 * @since 5.2.0 Make static.
264 *
265 * @return mixed JSON-decoded response body
266 **/
267 public static function getLanguages(): array|null|false
268 {
269 $url = sprintf('%s/organization/languages', Environment::getApiUrl());
270
271 $request = new Request('GET', $url);
272 $response = self::callApi($request);
273
274 return json_decode(wp_remote_retrieve_body($response), true);
275 }
276
277 /**
278 * GET /organization/voices
279 *
280 * @since 4.0.0 Introduced
281 * @since 4.0.2 Prefix endpoint with /organization
282 * @since 4.5.1 Check the $languageId param is numeric.
283 * @since 5.0.0 Accept numeric language ID or string language code as param.
284 * @since 5.2.0 Make static.
285 *
286 * @param int|string $language BeyondWords Language code or numeric ID
287 *
288 * @return mixed JSON-decoded response body
289 **/
290 public static function getVoices(int|string $languageCode): array|null|false
291 {
292 $url = sprintf(
293 '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary',
294 Environment::getApiUrl(),
295 urlencode(strval($languageCode))
296 );
297
298 $request = new Request('GET', $url);
299 $response = self::callApi($request);
300
301 return json_decode(wp_remote_retrieve_body($response), true);
302 }
303
304 /**
305 * Loops though GET /organization/voices, because
306 * GET /organization/voice is not available.
307 *
308 * @since 5.4.0
309 *
310 * @param int $voiceId Voice ID.
311 * @param int|false $languageCode Language code, optional.
312 *
313 * @return object|false Voice, or false if not found.
314 **/
315 public static function getVoice(int $voiceId, int|string|false $languageCode = false): object|array|false
316 {
317 if (! $languageCode) {
318 $languageCode = get_option('beyondwords_project_language_code');
319 }
320
321 $voices = self::getVoices($languageCode);
322
323 if (empty($voices)) {
324 return false;
325 }
326
327 return array_column($voices, null, 'id')[$voiceId] ?? false;
328 }
329
330 /**
331 * PUT /voices/:id.
332 *
333 * @since 5.0.0
334 * @since 5.2.0 Make static.
335 * @since 6.0.0 Cast body to string.
336 *
337 * @param array $settings Associative array of voice settings.
338 *
339 * @return mixed JSON-decoded response body
340 **/
341 public static function updateVoice(int $voiceId, array $settings): array|null|false
342 {
343 if (empty($voiceId)) {
344 return false;
345 }
346
347 $url = sprintf('%s/organization/voices/%d', Environment::getApiUrl(), $voiceId);
348
349 $body = (string) wp_json_encode($settings);
350
351 $request = new Request('PUT', $url, $body);
352 $response = self::callApi($request);
353
354 return json_decode(wp_remote_retrieve_body($response), true);
355 }
356
357 /**
358 * GET /projects/:id.
359 *
360 * @since 4.0.0
361 * @since 5.0.0 Cache response using transients
362 * @since 5.2.0 Make static.
363 *
364 * @return mixed JSON-decoded response body
365 **/
366 public static function getProject(): array|null|false
367 {
368 $projectId = get_option('beyondwords_project_id');
369
370 if (! $projectId) {
371 return false;
372 }
373
374 $url = sprintf('%s/projects/%d', Environment::getApiUrl(), $projectId);
375
376 $request = new Request('GET', $url);
377 $response = self::callApi($request);
378
379 return json_decode(wp_remote_retrieve_body($response), true);
380 }
381
382 /**
383 * PUT /projects/:id.
384 *
385 * @since 5.0.0
386 * @since 5.2.0 Make static.
387 * @since 6.0.0 Cast body to string.
388 *
389 * @param array $settings Associative array of project settings.
390 *
391 * @return mixed JSON-decoded response body
392 **/
393 public static function updateProject(array $settings): array|null|false
394 {
395 $projectId = get_option('beyondwords_project_id');
396
397 if (! $projectId) {
398 return false;
399 }
400
401 $url = sprintf('%s/projects/%d', Environment::getApiUrl(), $projectId);
402
403 $body = (string) wp_json_encode($settings);
404
405 $request = new Request('PUT', $url, $body);
406 $response = self::callApi($request);
407
408 return json_decode(wp_remote_retrieve_body($response), true);
409 }
410
411 /**
412 * GET /projects/:id/player_settings.
413 *
414 * @since 4.0.0
415 * @since 5.2.0 Make static.
416 *
417 * @return mixed JSON-decoded response body
418 **/
419 public static function getPlayerSettings(): array|null|false
420 {
421 $projectId = get_option('beyondwords_project_id');
422
423 if (! $projectId) {
424 return false;
425 }
426
427 $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId);
428
429 $request = new Request('GET', $url);
430 $response = self::callApi($request);
431
432 return json_decode(wp_remote_retrieve_body($response), true);
433 }
434
435 /**
436 * PUT /projects/:id/player_settings.
437 *
438 * @since 4.0.0
439 * @since 5.2.0 Make static.
440 * @since 6.0.0 Cast body to string.
441 *
442 * @param array $settings Associative array of player settings.
443 *
444 * @return mixed JSON-decoded response body
445 **/
446 public static function updatePlayerSettings(array $settings): array|null|false
447 {
448 $projectId = get_option('beyondwords_project_id');
449
450 if (! $projectId) {
451 return false;
452 }
453
454 $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId);
455
456 $body = (string) wp_json_encode($settings);
457
458 $request = new Request('PUT', $url, $body);
459 $response = self::callApi($request);
460
461 return json_decode(wp_remote_retrieve_body($response), true);
462 }
463
464 /**
465 * GET /projects/:id/video_settings.
466 *
467 * @since 4.1.0
468 * @since 5.0.0 Cache response using transients
469 * @since 5.2.0 Make static.
470 *
471 * @param int $projectId BeyondWords Project ID.
472 *
473 * @return mixed JSON-decoded response body
474 **/
475 public static function getVideoSettings(int|null $projectId = null): array|null|false
476 {
477 if (! $projectId) {
478 $projectId = get_option('beyondwords_project_id');
479
480 if (! $projectId) {
481 return false;
482 }
483 }
484
485 $url = sprintf('%s/projects/%d/video_settings', Environment::getApiUrl(), (int)$projectId);
486
487 $request = new Request('GET', $url);
488 $response = self::callApi($request);
489
490 return json_decode(wp_remote_retrieve_body($response), true);
491 }
492
493 /**
494 * Call the BeyondWords API backend, logging any errors if the requests for a particular post.
495 *
496 * @todo investigate whether we can move the logging into a WordPress HTTP filter.
497 *
498 * @since 3.0.0
499 * @since 3.9.0 Stop saving the speechkit_status post meta - downgrades to plugin v2.x are no longer expected.
500 * @since 4.0.0 Removed hash comparison.
501 * @since 4.4.0 Handle 204 responses with no body.
502 * @since 5.2.0 Make static, return result from wp_remote_request.
503 * @since 6.0.0 Add Magic Embed support and stop saving temporary request logs.
504 *
505 * @param Request $request Request.
506 * @param int $postId WordPress Post ID
507 *
508 * @return array|WP_Error The response array or a WP_Error on failure. See WP_Http::request() for
509 * information on return value.
510 **/
511 public static function callApi(Request $request, int|false $postId = false): array|\WP_Error
512 {
513 $post = get_post($postId);
514
515 // Delete existing errors before making this API call
516 self::deleteErrors($postId);
517
518 $args = self::buildRequestArgs($request);
519
520 // Get response
521 $response = wp_remote_request($request->getUrl(), $args);
522 $responseCode = wp_remote_retrieve_response_code($response);
523
524 // Mark API connection as invalid for 401 (API key may have been revoked)
525 if ($responseCode === 401) {
526 delete_option('beyondwords_valid_api_connection');
527 }
528
529 // Save error messages from WordPress HTTP errors and BeyondWords REST API error responses
530 if (
531 $post instanceof \WP_Post &&
532 IntegrationMethod::REST_API === IntegrationMethod::getIntegrationMethod($post) &&
533 (is_wp_error($response) || $responseCode > 299)
534 ) {
535 $message = self::errorMessageFromResponse($response);
536
537 self::saveErrorMessage($postId, $message, $responseCode);
538 }
539
540 return $response;
541 }
542
543 /**
544 * Build the request args for wp_remote_request().
545 *
546 * @since 3.0.0
547 * @since 4.0.0 Removed hash comparison and display 403 errors.
548 * @since 4.1.0 Introduced.
549 * @since 5.2.0 Make static.
550 * @since 5.2.2 Remove sslverify param & increase timeout to 30s for REST API calls.
551 * @since 6.0.0 Add user-agent.
552 *
553 * @param Request $request BeyondWords Request.
554 *
555 * @return array WordPress HTTP Request arguments.
556 */
557 public static function buildRequestArgs(Request $request): array
558 {
559 return [
560 'blocking' => true,
561 'body' => $request->getBody(),
562 'headers' => $request->getHeaders(),
563 'method' => $request->getMethod(),
564 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
565 ];
566 }
567
568 /**
569 * Error message from BeyondWords REST API response.
570 *
571 * @since 4.1.0
572 * @since 5.2.0 Make static.
573 *
574 * @param mixed[] $response BeyondWords REST API response.
575 *
576 * @return string Error message.
577 */
578 public static function errorMessageFromResponse(array|\WP_Error $response): string
579 {
580 $body = wp_remote_retrieve_body($response);
581 $body = json_decode($body, true);
582
583 $message = wp_remote_retrieve_response_message($response);
584
585 if (is_array($body)) {
586 if (array_key_exists('errors', $body)) {
587 $messages = [];
588
589 foreach ($body['errors'] as $error) {
590 $messages[] = implode(' ', array_values($error));
591 }
592
593 $message = implode(', ', $messages);
594 } elseif (array_key_exists('message', $body)) {
595 $message = $body['message'];
596 }
597 }
598
599 return $message;
600 }
601
602 /**
603 * Deletes errors for a post.
604 *
605 * @since 4.1.0 Introduced.
606 * @since 5.2.0 Make static.
607 *
608 * @param int $postId WordPress post ID.
609 */
610 public static function deleteErrors(int|false $postId): void
611 {
612 if (! $postId) {
613 return;
614 }
615
616 // Reset any existing errors before making this API call
617 delete_post_meta($postId, 'speechkit_error_message');
618 delete_post_meta($postId, 'beyondwords_error_message');
619 }
620
621 /**
622 * Add an error message for a post.
623 *
624 * This was updated in v6.0 to support Magic Embed. 404 errors are not saved for Magic Embed,
625 * because content will (re)generate when pages are visited.
626 *
627 * @since 4.1.0 Introduced.
628 * @since 4.4.0 Rename from error() to saveErrorMessage().
629 * @since 5.2.0 Make static.
630 * @since 6.0.0 Add Magic Embed support.
631 * @since 6.0.4 Fix bug where global integration method was checked instead of post meta.
632 * @since 6.0.4 Remove unnecessary extra sprintf() param for self::ERROR_FORMAT.
633 *
634 * @param int $postId WordPress post ID.
635 * @param string $message Error message.
636 * @param int $code Error code.
637 */
638 public static function saveErrorMessage(int|false $postId, string $message = '', int|string $code = 500): void
639 {
640 if (! $postId) {
641 return;
642 }
643
644 $post = get_post($postId);
645
646 // Don't save an error message for Client-side 404s - they will (re)generate when pages are visited.
647 if (
648 404 === $code &&
649 $post instanceof \WP_Post &&
650 IntegrationMethod::CLIENT_SIDE === IntegrationMethod::getIntegrationMethod($post)
651 ) {
652 return;
653 }
654
655 if (! $message) {
656 $message = sprintf(
657 /* translators: %s is replaced with the support email link */
658 esc_html__('API request error. Please contact %s.', 'speechkit'),
659 '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
660 );
661 }
662
663 if (! $code) {
664 $code = 500;
665 }
666
667 update_post_meta(
668 $postId,
669 'beyondwords_error_message',
670 sprintf(self::ERROR_FORMAT, (string)$code, $message)
671 );
672 }
673 }
674