PluginProbe
BeyondWords – AI audio for publishers / 6.0.3
BeyondWords – AI audio for publishers v6.0.3
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.0.3, at src/Core/ApiClient.php

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