PluginProbe
BeyondWords – AI audio for publishers / 4.5.0
BeyondWords – AI audio for publishers v4.5.0
7.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 All 44 releases
speechkit / src / Core / ApiClient.php

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

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