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

526 lines 14.6 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
12 /**
13 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
14 **/
15 class ApiClient
16 {
17 public const ERROR_FORMAT = '#%s: %s';
18
19 /**
20 * Init.
21 */
22 public function init()
23 {
24 add_action('admin_notices', array($this, 'adminNotices'));
25 }
26
27 /**
28 * POST /projects/:id/content.
29 *
30 * @since 3.0.0
31 *
32 * @param int $postId WordPress Post ID
33 *
34 * @return Response|false Response, or false
35 **/
36 public function createAudio($postId)
37 {
38 $projectId = PostMetaUtils::getProjectId($postId);
39
40 if (! $projectId) {
41 return false;
42 }
43
44 $url = sprintf('%s/projects/%d/content', Environment::getApiUrl(), $projectId);
45
46 $body = PostContentUtils::getContentParams($postId);
47
48 $request = new Request('POST', $url, $body);
49
50 return $this->callApi($request, $postId);
51 }
52
53 /**
54 * PUT /projects/:id/content/:id.
55 *
56 * @since 3.0.0
57 *
58 * @param int $postId WordPress Post ID
59 *
60 * @return Response|false Response, or false
61 **/
62 public function updateAudio($postId)
63 {
64 $projectId = PostMetaUtils::getProjectId($postId);
65 $contentId = PostMetaUtils::getContentId($postId);
66
67 if (! $projectId || ! $contentId) {
68 return false;
69 }
70
71 $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId);
72
73 $body = PostContentUtils::getContentParams($postId);
74
75 $request = new Request('PUT', $url, $body);
76
77 return $this->callApi($request, $postId);
78 }
79
80 /**
81 * DELETE /projects/:id/content/:id.
82 *
83 * @since 3.0.0
84 *
85 * @param int $postId WordPress Post ID
86 *
87 * @return Response|false Response, or false
88 **/
89 public function deleteAudio($postId)
90 {
91 $projectId = PostMetaUtils::getProjectId($postId);
92 $contentId = PostMetaUtils::getContentId($postId);
93
94 if (! $projectId || ! $contentId) {
95 return false;
96 }
97
98 $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId);
99
100 $request = new Request('DELETE', $url);
101
102 return $this->callApi($request, $postId);
103 }
104
105 /**
106 * DELETE /projects/:id/content/:id.
107 *
108 * @since 4.1.0
109 *
110 * @param int[] $postIds Array of WordPress Post IDs.
111 *
112 * @throws \Exception
113 * @return int[] The Post IDs with deleted audio.
114 **/
115 public function batchDeleteAudio($postIds)
116 {
117 $contentIds = [];
118 $updatedPostIds = [];
119
120 foreach ($postIds as $postId) {
121 $projectId = PostMetaUtils::getProjectId($postId);
122
123 if (! $projectId) {
124 continue;
125 }
126
127 $contentId = PostMetaUtils::getContentId($postId);
128
129 if (! $contentId) {
130 continue;
131 }
132
133 $contentIds[$projectId][] = $contentId;
134 $updatedPostIds[] = $postId;
135 }
136
137 if (! count($contentIds)) {
138 throw new \Exception(esc_html__('None of the selected posts had valid BeyondWords audio data.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong
139 }
140
141 if (count($contentIds) > 1) {
142 throw new \Exception(esc_html__('Batch delete can only be performed on audio belonging a single project.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong
143 }
144
145 $projectId = array_key_first($contentIds);
146
147 $url = sprintf('%s/projects/%d/content/batch_delete', Environment::getApiUrl(), $projectId);
148
149 $body = wp_json_encode(['ids' => $contentIds[$projectId]]);
150
151 $request = new Request('POST', $url, $body);
152
153 $args = array(
154 'blocking' => true,
155 'body' => $request->getBody(),
156 'headers' => $request->getHeaders(),
157 'method' => $request->getMethod(),
158 'sslverify' => true,
159 );
160
161 $response = wp_remote_request($request->getUrl(), $args);
162
163 // WordPress error performing API call
164 if (is_wp_error($response)) {
165 throw new \Exception(esc_html($response->get_error_message()));
166 }
167
168 $responseCode = wp_remote_retrieve_response_code($response);
169
170 if ($responseCode <= 299) {
171 // An OK response means all content IDs in the request were deleted
172 return $updatedPostIds;
173 } else {
174 // For non-OK responses we do not want to delete any custom fields,
175 // so return an empty array
176 return [];
177 }
178 }
179
180 /**
181 * GET /organization/languages
182 *
183 * @since 4.0.0 Introduced
184 * @since 4.0.2 Prefix endpoint with /organization
185 *
186 * @return array|object Array of voices or API error object.
187 **/
188 public function getLanguages()
189 {
190 $url = sprintf('%s/organization/languages', Environment::getApiUrl());
191
192 $request = new Request('GET', $url);
193
194 return $this->callApi($request);
195 }
196
197 /**
198 * GET /organization/voices
199 *
200 * @since 4.0.0 Introduced
201 * @since 4.0.2 Prefix endpoint with /organization
202 * @since 4.5.1 Check the $languageId param is numeric.
203 *
204 * @param int $languageId BeyondWords Language ID
205 *
206 * @return array|object Array of voices or API error object.
207 **/
208 public function getVoices($languageId)
209 {
210 if (! is_numeric($languageId)) {
211 return [];
212 }
213
214 $url = sprintf('%s/organization/voices?filter[language.id]=%s', Environment::getApiUrl(), urlencode(strval($languageId))); // phpcs:ignore Generic.Files.LineLength.TooLong
215
216 $request = new Request('GET', $url);
217
218 return $this->callApi($request);
219 }
220
221 /**
222 * GET /projects/:id.
223 *
224 * @since 4.0.0
225 *
226 * @return Response|false Response, or false
227 **/
228 public function getProject()
229 {
230 $projectId = get_option('beyondwords_project_id');
231
232 if (! $projectId) {
233 return false;
234 }
235
236 $url = sprintf('%s/projects/%d', Environment::getApiUrl(), $projectId);
237
238 $request = new Request('GET', $url);
239
240 return $this->callApi($request);
241 }
242
243 /**
244 * GET /projects/:id/player_settings.
245 *
246 * @since 4.0.0
247 *
248 * @return Response|false Response, or false
249 **/
250 public function getPlayerSettings()
251 {
252 $projectId = get_option('beyondwords_project_id');
253
254 if (! $projectId) {
255 return false;
256 }
257
258 $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId);
259
260 $request = new Request('GET', $url);
261
262 return $this->callApi($request);
263 }
264
265 /**
266 * PUT /projects/:id/player_settings.
267 *
268 * @since 4.0.0
269 *
270 * @param array $settings Associative array of player settings.
271 *
272 * @return Response|false Response, or false
273 **/
274 public function updatePlayerSettings($settings)
275 {
276 $projectId = get_option('beyondwords_project_id');
277
278 if (! $projectId) {
279 return false;
280 }
281
282 $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId);
283
284 $request = new Request('PUT', $url, wp_json_encode($settings));
285
286 return $this->callApi($request);
287 }
288
289 /**
290 * GET /projects/:id/video_settings.
291 *
292 * @since 4.1.0
293 *
294 * @param int $projectId BeyondWords Project ID.
295 *
296 * @return Response|false Response, or false
297 **/
298 public function getVideoSettings($projectId = null)
299 {
300 if (! $projectId) {
301 $projectId = get_option('beyondwords_project_id');
302
303 if (! $projectId) {
304 return false;
305 }
306 }
307
308 $url = sprintf('%s/projects/%d/video_settings', Environment::getApiUrl(), $projectId);
309
310 $request = new Request('GET', $url);
311
312 $request = new Request('GET', $url);
313
314 return $this->callApi($request);
315 }
316
317 /**
318 * Call the BeyondWords API backend.
319 *
320 * @since 3.0.0
321 * @since 3.9.0 Stop saving the speechkit_status post meta - downgrades to plugin v2.x are no longer expected.
322 * @since 4.0.0 Removed hash comparison.
323 * @since 4.4.0 Handle 204 responses with no body.
324 *
325 * @param Request $request Request.
326 * @param int $postId WordPress Post ID
327 *
328 * @return array|null|false JSON-decoded response body, or null for 204, or false on failure
329 **/
330 public function callApi($request, $postId = false)
331 {
332 // Pure
333 $args = $this->buildRequestArgs($request);
334
335 if ($postId) {
336 // Side-effect: db write
337 $this->deleteErrors($postId);
338
339 // Side-effect: db write
340 $this->addRequestLog($request, $args, $postId);
341 }
342
343 // WordPress core methods
344 $response = wp_remote_request($request->getUrl(), $args);
345 $responseCode = wp_remote_retrieve_response_code($response);
346 $responseMessage = wp_remote_retrieve_response_message($response);
347 $responseBody = json_decode(wp_remote_retrieve_body($response), true);
348
349 // 204 responses have no body
350 if ($responseCode === 204) {
351 return null;
352 }
353
354 // Handle HTTP errors
355 if (is_wp_error($response) || $responseCode > 299) {
356 // Prefer the response "message" field over the HTTP status message
357 if (is_array($responseBody) && array_key_exists('message', $responseBody)) {
358 $responseMessage = $responseBody['message'];
359 }
360 }
361
362 $responseBodyJson = wp_remote_retrieve_body($response);
363 $responseBody = json_decode($responseBodyJson, true);
364
365 // Response had a HTTP error code (3XX, 4XX, 5XX)
366 if ($responseCode > 299) {
367 $errorMessage = $this->errorMessageFromResponse($response);
368
369 $this->saveErrorMessage($postId, $responseMessage, $responseCode);
370
371 return false;
372 }
373
374 // Handle invalid JSON
375 if (json_last_error() !== JSON_ERROR_NONE) {
376 $errorMessage = sprintf(
377 /* translators: %s is replaced with the reason that JSON parsing failed */
378 __('Unable to parse JSON in BeyondWords API response. Reason: %s.', 'speechkit'),
379 // Don't allow any tags
380 wp_kses(json_last_error_msg(), [])
381 );
382
383 $this->saveErrorMessage($postId, $errorMessage, 500);
384
385 return false;
386 }
387
388 if ($postId) {
389 // Modifies db
390 $this->deleteRequestLog($postId);
391 }
392
393 return $responseBody;
394 }
395
396 /**
397 * Build the request args for wp_remote_request().
398 *
399 * @since 3.0.0
400 * @since 4.0.0 Removed hash comparison and display 403 errors.
401 * @since 4.1.0 Introduced.
402 *
403 * @param Request $request BeyondWords Request.
404 *
405 * @return array WordPress HTTP Request arguments.
406 */
407 public function buildRequestArgs($request)
408 {
409 return [
410 'blocking' => true,
411 'body' => $request->getBody(),
412 'headers' => $request->getHeaders(),
413 'method' => $request->getMethod(),
414 'sslverify' => true,
415 ];
416 }
417
418 /**
419 * Error message from BeyondWords REST API response.
420 *
421 * @since 4.1.0
422 *
423 * @param mixed[] $response BeyondWords REST API response.
424 *
425 * @return string Error message.
426 */
427 public function errorMessageFromResponse($response)
428 {
429 $body = wp_remote_retrieve_body($response);
430 $body = json_decode($body, true);
431
432 if (is_array($body) && array_key_exists('errors', $body)) {
433 $messages = [];
434
435 foreach ($body['errors'] as $error) {
436 $messages[] = implode(" ", array_values($error));
437 }
438
439 $message = implode(", ", $messages);
440 } elseif (is_array($body) && array_key_exists('message', $body)) {
441 $message = $body['message'];
442 } else {
443 $message = wp_remote_retrieve_response_message($response);
444 }
445
446 return $message;
447 }
448
449 /**
450 * Deletes errors for a post.
451 *
452 * @since 4.1.0 Introduced.
453 *
454 * @param int $postId WordPress post ID.
455 */
456 public function deleteErrors($postId)
457 {
458 // Reset any existing errors before making this API call
459 delete_post_meta($postId, 'speechkit_error_message');
460 delete_post_meta($postId, 'beyondwords_error_message');
461 }
462
463 /**
464 * Log the request details for a post.
465 *
466 * @since 4.1.0 Introduced.
467 *
468 * @param int $postId WordPress post ID.
469 * @param Request $request BeyondWords Request.
470 *
471 * @param array WordPress HTTP Request arguments.
472 */
473 public function addRequestLog($request, $args, $postId)
474 {
475 // Log the request URL and args for debugging
476 // (these are removed for successful requests)
477 update_post_meta($postId, 'beyondwords_request_url', $request->getUrl());
478 update_post_meta($postId, 'beyondwords_request_args', var_export($args, true)); // phpcs:ignore
479 }
480
481 /**
482 * Deletes request details for a post.
483 *
484 * @since 4.1.0 Introduced.
485 *
486 * @param int $postId WordPress post ID.
487 */
488 public function deleteRequestLog($postId)
489 {
490 // Success, so remove request URL and args log
491 delete_post_meta($postId, 'beyondwords_request_url');
492 delete_post_meta($postId, 'beyondwords_request_args');
493 }
494
495 /**
496 * Add an error message for a post.
497 *
498 * @since 4.1.0 Introduced.
499 * @since 4.4.0 Rename from error() to saveErrorMessage().
500 *
501 * @param int $postId WordPress post ID.
502 * @param string $message Error message.
503 * @param int $code Error code.
504 */
505 public function saveErrorMessage($postId, $message = '', $code = 500)
506 {
507 if (! $message) {
508 $message = sprintf(
509 /* translators: %s is replaced with the support email link */
510 esc_html__('API request error. Please contact %s.', 'speechkit'),
511 '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
512 );
513 }
514
515 if (! $code) {
516 $code = 500;
517 }
518
519 update_post_meta(
520 $postId,
521 'beyondwords_error_message',
522 sprintf(self::ERROR_FORMAT, (string)$code, $message, $code)
523 );
524 }
525 }
526