PluginProbe
Social Media Auto Poster – Schedule & Publish to Buffer / trunk
Social Media Auto Poster – Schedule & Publish to Buffer vtrunk
6.2.4 6.2.3 6.2.2 6.2.1 6.2.0 6.1.2 6.1.1 6.1.0 6.0.9 6.0.8 6.0.7 6.0.6 6.0.5 6.0.4 6.0.3 6.0.2 6.0.1 6.0.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 All 125 releases
wp-to-buffer / includes / class-buffer-api.php

class-buffer-api.php in Social Media Auto Poster – Schedule & Publish to Buffer trunk, at includes/class-buffer-api.php

1,380 lines 38.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Buffer API class
4 *
5 * @package WPZinc\Social
6 * @author WP Zinc
7 */
8
9 namespace WPZinc\Social;
10
11 /**
12 * Provides functions for sending statuses and querying Buffer's API.
13 *
14 * @package WPZinc\Social
15 * @author WP Zinc
16 * @version 3.0.0
17 */
18 class Buffer_API {
19
20 /**
21 * Holds the base class object.
22 *
23 * @since 3.4.7
24 *
25 * @var object
26 */
27 public $base;
28
29 /**
30 * Holds the Buffer Application's Client ID
31 *
32 * @since 3.3.3
33 *
34 * @var string
35 */
36 private $client_id = 'd1Lk26lma4iEgb-20v1BWmdKrlopiGwP9pu9ri7JG0e';
37
38 /**
39 * Holds the oAuth Authorize URL
40 *
41 * @since 6.0.0
42 *
43 * @var string
44 */
45 private $oauth_authorize_url = 'https://auth.buffer.com/';
46
47 /**
48 * Holds the oAuth Gateway endpoint, used to exchange a code for an access token
49 *
50 * @since 3.3.3
51 *
52 * @var string
53 */
54 private $redirect_uri = 'https://www.wpzinc.com/?oauth=bufferv2';
55
56 /**
57 * Holds the API endpoint
58 *
59 * @since 3.4.7
60 *
61 * @var string
62 */
63 private $api_endpoint = 'https://api.buffer.com/';
64
65 /**
66 * Access Token
67 *
68 * @since 3.0.0
69 *
70 * @var string
71 */
72 public $access_token = '';
73
74 /**
75 * Refresh Token
76 *
77 * @since 3.4.7
78 *
79 * @var string
80 */
81 public $refresh_token = '';
82
83 /**
84 * Token Expiry Timestamp
85 *
86 * @since 3.5.0
87 *
88 * @var int|bool
89 */
90 public $token_expires = false;
91
92 /**
93 * Constructor
94 *
95 * @since 3.4.7
96 *
97 * @param object $base Base Plugin Class.
98 */
99 public function __construct( $base ) {
100
101 // Store base class.
102 $this->base = $base;
103
104 add_action( 'wp_to_buffer_output_auth', array( $this, 'output_oauth' ) );
105 add_action( 'wp_to_buffer_pro_output_auth', array( $this, 'output_oauth' ) );
106
107 }
108
109 /**
110 * Outputs an Authorize Plugin button on Settings > General when the Plugin needs to be authenticated with Buffer.
111 *
112 * @since 4.2.0
113 */
114 public function output_oauth() {
115
116 ?>
117 <div class="wpzinc-option">
118 <div class="full">
119 <a href="<?php echo esc_attr( $this->get_oauth_url() ); ?>" class="button button-primary">
120 <?php esc_html_e( 'Connect an additional Buffer Account', 'wp-to-buffer' ); ?>
121 </a>
122 </div>
123 </div>
124 <?php
125
126 }
127
128 /**
129 * Generates and stores a code verifier for PKCE authentication flow.
130 *
131 * @since 6.0.0
132 *
133 * @return string
134 */
135 private function generate_and_store_code_verifier() {
136
137 // If a code verifier already exists, use it.
138 $code_verifier = $this->get_code_verifier();
139 if ( $code_verifier ) {
140 return $code_verifier;
141 }
142
143 // Generate a random string.
144 $code_verifier = random_bytes( 64 );
145
146 // Encode to Base64 string.
147 $code_verifier = $this->base64_urlencode( $code_verifier );
148
149 // Store in database for later use.
150 update_option( 'wp_to_buffer_pro_code_verifier', $code_verifier );
151
152 // Return.
153 return $code_verifier;
154
155 }
156
157 /**
158 * Base64URL the given code verifier, as PHP has no built in function for this.
159 *
160 * @since 6.0.0
161 *
162 * @param string $code_verifier Code Verifier.
163 * @return string Code Challenge.
164 */
165 public function generate_code_challenge( $code_verifier ) {
166
167 // Hash using S256.
168 $code_challenge = hash( 'sha256', $code_verifier, true );
169
170 // Encode to Base64 string.
171 $code_challenge = base64_encode( $code_challenge ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
172
173 // Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”.
174 $code_challenge = strtr( $code_challenge, '+/', '-_' );
175
176 // Remove padding character from the end of line.
177 $code_challenge = rtrim( $code_challenge, '=' );
178
179 // Return.
180 return $code_challenge;
181
182 }
183
184 /**
185 * Returns the stored code verifier generated by generate_and_store_code_verifier().
186 *
187 * @since 6.0.0
188 *
189 * @return bool|string
190 */
191 public function get_code_verifier() {
192
193 return get_option( 'wp_to_buffer_pro_code_verifier' );
194
195 }
196
197 /**
198 * Deletes the stored code verifier generated by generate_code_verifier().
199 *
200 * @since 6.0.0
201 *
202 * @return bool
203 */
204 private function delete_code_verifier() {
205
206 return delete_option( 'wp_to_buffer_pro_code_verifier' );
207
208 }
209
210 /**
211 * Base64URL encode the given string.
212 *
213 * @since 6.0.0
214 *
215 * @param string $str String to encode.
216 * @return string Encoded string.
217 */
218 public function base64_urlencode( $str ) {
219
220 // Encode to Base64 string.
221 $str = base64_encode( $str ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
222
223 // Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”.
224 $str = strtr( $str, '+/', '-_' );
225
226 // Remove padding character from the end of line.
227 $str = rtrim( $str, '=' );
228
229 return $str;
230
231 }
232
233 /**
234 * Returns the oAuth 2 URL used to begin the oAuth process
235 *
236 * @since 3.3.3
237 *
238 * @param string $account_id Existing Account ID (if included, is passed in the `state` parameter and ultimately back to the WordPress site to replace an account later on).
239 * @return string oAuth URL
240 */
241 public function get_oauth_url( $account_id = '' ) {
242
243 // Generate and store code verifier and challenge.
244 $code_verifier = $this->generate_and_store_code_verifier();
245 $code_challenge = $this->generate_code_challenge( $code_verifier );
246
247 // Generate return URL, including a nonce to protect against OAuth callback CSRF.
248 $return_url = add_query_arg(
249 '_wpnonce',
250 wp_create_nonce( $this->base->plugin->filter_name . '_oauth' ),
251 admin_url( 'admin.php?page=' . $this->base->plugin->name . '-settings' )
252 );
253 if ( ! empty( $account_id ) ) {
254 $return_url = add_query_arg(
255 array(
256 'account_id' => $account_id,
257 ),
258 $return_url
259 );
260 }
261
262 // Build args.
263 $args = array(
264 'client_id' => $this->client_id,
265 'redirect_uri' => $this->redirect_uri,
266 'response_type' => 'code',
267 'scope' => 'posts:write posts:read ideas:read ideas:write account:read account:write offline_access',
268 'state' => rawurlencode( $return_url ),
269 'code_challenge' => $code_challenge,
270 'code_challenge_method' => 'S256',
271 'prompt' => 'consent',
272 );
273
274 // Return OAuth URL.
275 return add_query_arg(
276 $args,
277 $this->oauth_authorize_url . 'auth'
278 );
279
280 }
281
282 /**
283 * Returns the Buffer URL where the user can register for a Buffer account
284 *
285 * @since 4.6.4
286 *
287 * @return string URL
288 */
289 public function get_registration_url() {
290
291 return 'https://join.buffer.com/wpzinc';
292
293 }
294
295 /**
296 * Returns the Buffer URL where the user can connect their social media accounts
297 * to Buffer
298 *
299 * @since 3.8.4
300 *
301 * @return string URL
302 */
303 public function get_connect_profiles_url() {
304
305 return 'https://publish.buffer.com/settings/channels';
306
307 }
308
309 /**
310 * Returns the Buffer URL where the user can change the timezone for the
311 * given profile ID.
312 *
313 * @since 3.8.1
314 *
315 * @param string $profile_id Profile ID.
316 * @return string Timezone Settings URL
317 */
318 public function get_timezone_settings_url( $profile_id ) {
319
320 return 'https://publish.buffer.com/profile/' . $profile_id . '/tab/settings/postingSchedule';
321
322 }
323
324 /**
325 * Sets this class' access and refresh tokens
326 *
327 * @since 1.0.0
328 *
329 * @param string $access_token Access Token.
330 * @param string $refresh_token Refresh Token.
331 * @param bool|int $token_expires Token Expiry.
332 */
333 public function set_tokens( $access_token = '', $refresh_token = '', $token_expires = false ) {
334
335 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): Started.' );
336 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): access_token = ' . $access_token );
337 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): refresh_token = ' . $refresh_token );
338 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: set_tokens(): token_expires = ' . $token_expires );
339
340 $this->access_token = $access_token;
341 $this->refresh_token = $refresh_token;
342 $this->token_expires = $token_expires;
343
344 }
345
346 /**
347 * Exchanges the given code for an access token, refresh token and other data.
348 *
349 * @since 6.0.0
350 *
351 * @param string $authorization_code Authorization Code, returned from get_oauth_url() flow.
352 * @return \WP_Error|array
353 */
354 public function get_access_token( $authorization_code ) {
355
356 $result = $this->oauth_request(
357 $this->oauth_authorize_url . 'token',
358 array(
359 'client_id' => $this->client_id,
360 'grant_type' => 'authorization_code',
361 'code' => $authorization_code,
362 'redirect_uri' => $this->redirect_uri,
363 'code_verifier' => $this->get_code_verifier(),
364 )
365 );
366
367 // Delete code verifier, as it's no longer needed.
368 // If the access token request fails, the user
369 // will begin the process again, which generates a
370 // new code verifier.
371 $this->delete_code_verifier();
372
373 // If an error occured, return it now.
374 if ( is_wp_error( $result ) ) {
375 return $result;
376 }
377
378 // Update the access and refresh tokens in this class.
379 $this->set_tokens( $result['access_token'], $result['refresh_token'], strtotime( '+' . $result['expires_in'] . ' seconds' ) );
380
381 // Return data.
382 return array(
383 'access_token' => $this->access_token,
384 'refresh_token' => $this->refresh_token,
385 'token_expires' => $this->token_expires,
386 );
387
388 }
389
390 /**
391 * Fetches a new access token using the supplied refresh token.
392 *
393 * @since 2.0.0
394 */
395 public function refresh_token() {
396
397 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Started.' );
398
399 // Bail if we don't have a refresh token.
400 if ( empty( $this->refresh_token ) ) {
401 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Error: No refresh token available; cannot refresh.' );
402 return new \WP_Error( 'missing_refresh_token', __( 'No refresh token exists', 'wp-to-buffer' ) );
403 }
404
405 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): access_token = ' . $this->access_token );
406 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): refresh_token = ' . $this->refresh_token );
407 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): token_expires = ' . $this->token_expires );
408 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) );
409
410 // Bail if the access token hasn't yet expired.
411 if ( strtotime( '+15 minutes' ) < $this->token_expires ) {
412 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Skipped: Access token not yet within the 15 minute refresh window.' );
413 return false;
414 }
415
416 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Requesting new access token.' );
417
418 // Send request.
419 $result = $this->oauth_request(
420 $this->oauth_authorize_url . 'token',
421 array(
422 'client_id' => $this->client_id,
423 'grant_type' => 'refresh_token',
424 'refresh_token' => $this->refresh_token,
425 )
426 );
427
428 // If an error occured, log and return it now.
429 if ( is_wp_error( $result ) ) {
430 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: refresh_token(): Error: [%s] %s', $result->get_error_code(), $result->get_error_message() ) );
431
432 /**
433 * Perform any actions when refreshing an expired access token fails.
434 *
435 * @since 6.0.0
436 *
437 * @param \WP_Error $result Error from API.
438 * @param string $client_id OAuth Client ID.
439 * @param string $access_token Access Token.
440 * @param string $refresh_token Refresh Token.
441 */
442 do_action( $this->base->plugin->filter_name . '_api_refresh_token_error', $result, $this->client_id, $this->access_token, $this->refresh_token );
443
444 return $result;
445 }
446
447 // Build result data.
448 $result = array(
449 'access_token' => $result['access_token'],
450 'refresh_token' => $result['refresh_token'],
451 'token_expires' => strtotime( '+' . $result['expires_in'] . ' seconds' ),
452 );
453
454 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): Success' );
455 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New access_token = ' . $result['access_token'] );
456 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New refresh_token = ' . $result['refresh_token'] );
457 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New token_expires = ' . $result['token_expires'] );
458 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: refresh_token(): New token_expires = ' . ( $result['token_expires'] && time() > (int) $result['token_expires'] ? 'expired ' . ( time() - (int) $result['token_expires'] ) . 's ago' : 'expires in ' . ( (int) $result['token_expires'] - time() ) . 's' ) );
459
460 /**
461 * Perform any actions with the new access token, such as saving it.
462 *
463 * @since 6.0.0
464 *
465 * @param array $result New Access Token, Refresh Token and Expiry timestamp.
466 * @param string $client_id OAuth Client ID.
467 * @param string $previous_access_token Existing Access Token.
468 * @param string $previous_refresh_token Existing Refresh Token.
469 */
470 do_action( $this->base->plugin->filter_name . '_api_refresh_token', $result, $this->client_id, $this->access_token, $this->refresh_token );
471
472 // Update the access and refresh tokens in this class.
473 $this->set_tokens( $result['access_token'], $result['refresh_token'], $result['token_expires'] );
474
475 // Return new access token, refresh token and expiry timestamp.
476 return $result;
477
478 }
479
480 /**
481 * Returns the organizations the current account is a member of.
482 *
483 * @since 6.0.1
484 *
485 * @param bool $force Force API call (false = use stored option).
486 *
487 * @return \WP_Error|array
488 */
489 public function organizations( $force = false ) {
490
491 // Return stored organizations from the non-autoloaded option, unless
492 // forcing a refresh from the API.
493 $option_name = $this->base->plugin->name . '-organizations';
494 $organizations = get_option( $option_name );
495 if ( $force || ! is_array( $organizations ) ) {
496 // Build GraphQL query.
497 $query = '
498 query {
499 account {
500 organizations {
501 id
502 name
503 ownerEmail
504 limits {
505 channels
506 scheduledPosts
507 }
508 }
509 }
510 }';
511
512 // Query API.
513 $result = $this->graphql_query( $query );
514
515 // Bail if an error occurred.
516 if ( is_wp_error( $result ) ) {
517 return $result;
518 }
519
520 // Build the organizations array. Reset to an empty array first, as
521 // get_option() returns false when the option does not exist.
522 $organizations = array();
523
524 foreach ( $result['data']['account']['organizations'] as $organization ) {
525 $organizations[ $organization['id'] ] = array(
526 'id' => $organization['id'],
527 'name' => $organization['name'],
528 'email' => $organization['ownerEmail'],
529 'channel_limit' => $organization['limits']['channels'],
530 'plan' => $organization['limits']['scheduledPosts'] === 10 ? 'free' : 'paid',
531 );
532 }
533
534 // Store organizations in a non-autoloaded option so they persist
535 // across object cache eviction, and don't load on every WP request.
536 update_option( $option_name, $organizations, false );
537 }
538
539 return $organizations;
540
541 }
542
543 /**
544 * Returns the account within the organization with the given ID.
545 *
546 * @since 6.0.0
547 *
548 * @param string $account_id Account ID.
549 * @return \WP_Error|array
550 */
551 public function account( $account_id = '' ) {
552
553 $organizations = $this->organizations( false );
554
555 // Bail if an error occurred.
556 if ( is_wp_error( $organizations ) ) {
557 return $organizations;
558 }
559
560 // Bail if the organization is not found.
561 if ( ! array_key_exists( $account_id, $organizations ) ) {
562 return new \WP_Error( 'organization_not_found', __( 'Organization not found.', 'wp-to-buffer' ) );
563 }
564
565 return $organizations[ $account_id ];
566
567 }
568
569 /**
570 * Returns a list of Social Media Profiles attached to the Buffer Account.
571 *
572 * Profiles are cached in a non-autoloaded option. Persistence survives
573 * object cache eviction. Callers force a refresh via the Refresh Profiles
574 * button in Settings > Authentication.
575 *
576 * @since 3.0.0
577 *
578 * @param bool $force Force API call (false = use stored option).
579 * @param string $account_id Account ID.
580 * @return \WP_Error|array
581 */
582 public function profiles( $force = false, $account_id = 'default' ) {
583
584 // Return stored profiles if available and not forcing a refresh.
585 $option_name = $this->base->plugin->name . '-profiles-' . $account_id;
586 $profiles = get_option( $option_name );
587 if ( ! $force && is_array( $profiles ) ) {
588 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: returning %d cached profile(s).', $account_id, count( $profiles ) ) );
589 return $profiles;
590 }
591
592 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: fetching profiles from Buffer (force=%s).', $account_id, $force ? 'yes' : 'no' ) );
593
594 // Build GraphQL query.
595 $query = '
596 query GetChannels($organizationId: OrganizationId!) {
597 channels(input: { organizationId: $organizationId }) {
598 id
599 descriptor
600 name
601 service
602 serviceId
603 timezone
604 metadata {
605 ... on PinterestMetadata {
606 boards {
607 id
608 serviceId
609 name
610 }
611 }
612 }
613 }
614 }';
615
616 // Get profiles.
617 $results = $this->graphql_query(
618 $query,
619 array(
620 'organizationId' => $account_id,
621 )
622 );
623
624 // Check for errors.
625 if ( is_wp_error( $results ) ) {
626 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: profiles(): account=%s: FAILED to fetch profiles: [%s] %s', $account_id, $results->get_error_code(), $results->get_error_message() ) );
627 return $results;
628 }
629
630 // Build profiles array from results.
631 $profiles = array();
632 foreach ( $results['data']['channels'] as $channel ) {
633 $profiles[ $channel['id'] ] = array(
634 'id' => $channel['id'], // Buffer ID. Buffer uses this for the ID when creating a post.
635 'formatted_service' => $channel['descriptor'],
636 'formatted_username' => $channel['name'],
637 'service' => $channel['service'],
638 'timezone' => $channel['timezone'],
639 'can_be_subprofile' => false, // For pinterest, the profile is the account, not the board.
640 );
641
642 // Pinterest: Add Boards as Subprofiles.
643 switch ( $channel['service'] ) {
644 case 'pinterest':
645 $profiles[ $channel['id'] ]['subprofiles'] = array();
646 foreach ( $channel['metadata']['boards'] as $board ) {
647 $profiles[ $channel['id'] ]['subprofiles'][ $board['serviceId'] ] = array(
648 'id' => $board['serviceId'], // Social Network (Pinterest) ID. Buffer uses this for the ID when creating a Pin. Yes, it's different from ['id'] above.
649 'name' => $board['name'],
650 'service' => $channel['service'],
651 );
652 }
653 break;
654 }
655 }
656
657 // Store profiles in a non-autoloaded option so they persist across
658 // object cache eviction, and don't load on every WP request.
659 update_option( $option_name, $profiles, false );
660
661 return $profiles;
662
663 }
664
665 /**
666 * Returns a single post by its ID.
667 *
668 * @since 6.0.0
669 *
670 * @param string $post_id Post ID.
671 * @return \WP_Error|array
672 */
673 public function get_post( $post_id ) {
674
675 $query = '
676 query GetPost($postId: PostId!) {
677 post(input: { id: $postId }) {
678 id
679 status
680 text
681 metadata {
682 ... on FacebookPostMetadata {
683 annotations {
684 type
685 content
686 indices
687 text
688 url
689 }
690 }
691 }
692 }
693 }';
694
695 $result = $this->graphql_query(
696 $query,
697 array(
698 'postId' => $post_id,
699 )
700 );
701
702 if ( is_wp_error( $result ) ) {
703 return $result;
704 }
705
706 return $result['data']['post'];
707
708 }
709
710 /**
711 * Creates an update (status)
712 *
713 * @since 3.0.0
714 *
715 * @param array $params Params.
716 * @param string $service Service.
717 * @return \WP_Error|array
718 */
719 public function updates_create( $params, $service ) {
720
721 // Build GraphQL variables.
722 // assets defaults to an empty array; Buffer's schema requires a
723 // non-null list even for text-only posts, so we always send this key.
724 $variables = array(
725 'channelId' => $params['profile_ids'][0],
726 'text' => $params['text'],
727 'schedulingType' => 'automatic',
728 'mode' => 'addToQueue',
729 'source' => 'wp-buffer',
730 'assets' => array(),
731 );
732 $assets = array();
733
734 // Scheduling.
735 switch ( $params['schedule_type'] ) {
736 case 'queue_end':
737 $variables['mode'] = 'addToQueue';
738 break;
739
740 case 'queue_start':
741 $variables['mode'] = 'shareNext';
742 break;
743
744 case 'immediate':
745 $variables['mode'] = 'shareNow';
746 break;
747
748 default:
749 // If no scheduled_at is set, add to end of queue.
750 if ( ! array_key_exists( 'scheduled_at', $params ) ) {
751 $variables['mode'] = 'addToQueue';
752 break;
753 }
754 if ( empty( $params['scheduled_at'] ) ) {
755 $variables['mode'] = 'addToQueue';
756 break;
757 }
758
759 $variables['mode'] = 'customScheduled';
760 $variables['dueAt'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $params['scheduled_at'] ) );
761 break;
762 }
763
764 // Draft.
765 if ( array_key_exists( 'is_draft', $params ) ) {
766 $variables['saveToDraft'] = (bool) $params['is_draft'];
767 }
768
769 // Metadata.
770 $metadata = array();
771 switch ( $service ) {
772
773 case 'instagram':
774 $metadata = array(
775 'type' => in_array( $params['post_type'], array( 'story', 'video_story' ), true ) ? 'story' : 'post',
776 'shouldShareToFeed' => true,
777 );
778
779 // First Comment.
780 if ( ! empty( $params['first_comment'] ) ) {
781 $metadata['firstComment'] = $params['first_comment'];
782 }
783
784 // Shop Grid Link.
785 if ( ! empty( $params['url'] ) ) {
786 $metadata['link'] = $params['url'];
787 }
788 break;
789
790 case 'facebook':
791 $metadata = array(
792 'type' => 'post',
793 );
794
795 // First Comment.
796 if ( ! empty( $params['first_comment'] ) ) {
797 $metadata['firstComment'] = $params['first_comment'];
798 }
799
800 // OpenGraph / Link Attachment.
801 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
802 $metadata['linkAttachment'] = array(
803 'url' => $params['url'],
804 );
805 }
806
807 // Annotations.
808 if ( array_key_exists( 'annotations', $params ) ) {
809 $metadata['annotations'] = $params['annotations'];
810 }
811 break;
812
813 case 'linkedin':
814 // First Comment.
815 if ( ! empty( $params['first_comment'] ) ) {
816 $metadata['firstComment'] = $params['first_comment'];
817 }
818
819 // OpenGraph / Link Attachment.
820 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
821 $metadata['linkAttachment'] = array(
822 'url' => $params['url'],
823 );
824 }
825
826 // Annotations.
827 if ( array_key_exists( 'annotations', $params ) ) {
828 $metadata['annotations'] = $params['annotations'];
829 }
830 break;
831
832 case 'twitter':
833 // First Comment.
834 if ( ! empty( $params['first_comment'] ) ) {
835 $metadata['thread'] = array(
836 array(
837 'text' => $params['first_comment'],
838 ),
839 );
840 }
841
842 // OpenGraph / Link Attachment.
843 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
844 $variables['text'] .= ' ' . $params['url'];
845 }
846 break;
847
848 case 'pinterest':
849 $metadata = array(
850 'title' => $params[ $service ]['title'],
851 'url' => $params['url'],
852 'boardServiceId' => $params[ $service ]['board'],
853 );
854 break;
855
856 case 'googlebusiness':
857 // Post Type.
858 $metadata = array(
859 'type' => $params[ $service ]['post_type'],
860 );
861
862 // Depending on the Post Type, add the appropriate metadata.
863 switch ( $metadata['type'] ) {
864 case 'whats_new':
865 $metadata['detailsWhatsNew'] = array(
866 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
867 'link' => $params['url'],
868 );
869 break;
870 case 'event':
871 $metadata['detailsEvent'] = array(
872 'title' => $params[ $service ]['title'],
873 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
874 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
875 'isFullDayEvent' => false,
876 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
877 'link' => $params['url'],
878 );
879 break;
880
881 case 'offer':
882 $metadata['detailsOffer'] = array(
883 'title' => $params[ $service ]['title'],
884 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
885 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
886 'code' => $params[ $service ]['code'],
887 'link' => $params['url'],
888 'terms' => $params[ $service ]['terms'],
889 );
890 break;
891 }
892 break;
893
894 case 'threads':
895 // OpenGraph / Link Attachment.
896 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
897 $metadata['linkAttachment'] = array(
898 'url' => $params['url'],
899 );
900 }
901
902 // First Comment.
903 if ( ! empty( $params['first_comment'] ) ) {
904 $metadata['thread'] = array(
905 array(
906 'text' => $params['first_comment'],
907 ),
908 );
909 }
910 break;
911
912 case 'bluesky':
913 // OpenGraph / Link Attachment.
914 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
915 $metadata['linkAttachment'] = array(
916 'url' => $params['url'],
917 );
918 }
919
920 // First Comment.
921 if ( ! empty( $params['first_comment'] ) ) {
922 $metadata['thread'] = array(
923 array(
924 'text' => $params['first_comment'],
925 ),
926 );
927 }
928 break;
929
930 case 'mastodon':
931 // First Comment.
932 if ( ! empty( $params['first_comment'] ) ) {
933 $metadata['thread'] = array(
934 array(
935 'text' => $params['first_comment'],
936 ),
937 );
938 }
939
940 // OpenGraph / Link Attachment.
941 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
942 $variables['text'] .= ' ' . $params['url'];
943 }
944 break;
945 }
946
947 // Add metadata.
948 if ( ! empty( $metadata ) ) {
949 $variables['metadata'] = array(
950 ( $service === 'googlebusiness' ? 'google' : $service ) => $metadata,
951 );
952 }
953
954 // Assets / Images.
955 switch ( $params['post_type'] ) {
956 case 'image':
957 case 'story':
958 case 'pin':
959 case 'googlebusiness':
960 // Bail if no images are defined.
961 if ( ! array_key_exists( 'media_urls', $params ) ) {
962 break;
963 }
964
965 // Build assets array.
966 $assets = array();
967 foreach ( $params['media_urls'] as $media ) {
968 // Skip anything that isn't an image, so we never send null asset data.
969 if ( ! is_array( $media ) || empty( $media['image'] ) ) {
970 continue;
971 }
972
973 $assets[] = array(
974 'image' => array(
975 'url' => $media['image'],
976 'thumbnailUrl' => $media['thumbnail'],
977 'metadata' => array(
978 'altText' => $media['alt_text'],
979 ),
980 ),
981 );
982 }
983 break;
984
985 case 'video':
986 case 'video_story':
987 // Bail if no video URL is defined.
988 if ( empty( $params['video_url'] ) ) {
989 break;
990 }
991
992 // Buffer fetches the video from a public URL, so we only send the URL.
993 $assets = array(
994 array(
995 'video' => array(
996 'url' => $params['video_url'],
997 ),
998 ),
999 );
1000 break;
1001 }
1002
1003 // Include assets. Always overwrites the default empty array
1004 // initialised above, if the service branch built any.
1005 if ( ! empty( $assets ) ) {
1006 $variables['assets'] = $assets;
1007 }
1008
1009 // Build GraphQL query.
1010 $query = '
1011 mutation CreatePost(
1012 $channelId: ChannelId!
1013 $text: String
1014 $schedulingType: SchedulingType!
1015 $mode: ShareMode!
1016 $source: String
1017 $dueAt: DateTime
1018 $saveToDraft: Boolean
1019 $assets: [AssetInput!]!
1020 $metadata: PostInputMetaData
1021 ) {
1022 createPost(input: {
1023 channelId: $channelId
1024 text: $text
1025 schedulingType: $schedulingType
1026 mode: $mode
1027 source: $source
1028 dueAt: $dueAt
1029 saveToDraft: $saveToDraft
1030 assets: $assets
1031 metadata: $metadata
1032 }) {
1033 ... on PostActionSuccess {
1034 post {
1035 id
1036 text
1037 status
1038 dueAt
1039 }
1040 }
1041 ... on MutationError {
1042 message
1043 }
1044 }
1045 }';
1046
1047 // Send update.
1048 $result = $this->graphql_query( $query, $variables );
1049
1050 // Bail if the result is an error.
1051 if ( is_wp_error( $result ) ) {
1052 return $result;
1053 }
1054
1055 // Return array of just the data we need to send to the Plugin.
1056 return array(
1057 'profile_id' => $params['profile_ids'][0], // API doesn't return the Profile ID.
1058 'message' => $result['data']['createPost']['post']['status'],
1059 'status_text' => $result['data']['createPost']['post']['text'],
1060
1061 // Both must be UTC timestamps.
1062 'status_created_at' => strtotime( 'now' ),
1063 // due_at won't exist if is_draft = 'true' when the update was created.
1064 'due_at' => ( isset( $result['data']['createPost']['post']['dueAt'] ) ? strtotime( $result['data']['createPost']['post']['dueAt'] ) : '0000-00-00 00:00:00' ),
1065 );
1066
1067 }
1068
1069 /**
1070 * Main function for fetching access tokens and refreshing existing tokens.
1071 *
1072 * All requests are sent using POST, as the Buffer API uses GraphQL:
1073 * https://developers.buffer.com/guides/rest-migration.html
1074 *
1075 * @since 3.0.0
1076 *
1077 * @param string $url URL.
1078 * @param array $params Parameters (optional).
1079 * @return \WP_Error|array
1080 */
1081 private function oauth_request( $url, $params = array() ) {
1082
1083 // Send request.
1084 $result = wp_remote_post(
1085 $url,
1086 array(
1087 'headers' => array(
1088 'Accept' => 'application/json',
1089 'Content-Type' => 'application/x-www-form-urlencoded',
1090 ),
1091 'body' => $this->get_body( $params, 'application/x-www-form-urlencoded' ),
1092 'timeout' => $this->get_timeout(),
1093 'user-agent' => $this->get_user_agent(),
1094 )
1095 );
1096
1097 // If an error occured, return it now.
1098 if ( is_wp_error( $result ) ) {
1099 return $result;
1100 }
1101
1102 // Parse response and return.
1103 return $this->parse_response( $result );
1104
1105 }
1106
1107 /**
1108 * Main function which handles sending requests to Buffer API's
1109 * GraphQL endpoints.
1110 *
1111 * All requests are sent using POST, as the Buffer API uses GraphQL:
1112 * https://developers.buffer.com/guides/rest-migration.html
1113 *
1114 * @since 6.0.0
1115 *
1116 * @param string $query GraphQL Query.
1117 * @param array $variables GraphQL Variables.
1118 * @return \WP_Error|array
1119 */
1120 private function graphql_query( $query, $variables = array() ) {
1121
1122 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Started.' );
1123 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): query = ' . $query );
1124 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): variables = ' . print_r( $variables, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
1125 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): access_token = ' . $this->access_token );
1126 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): refresh_token = ' . $this->refresh_token );
1127 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . $this->token_expires );
1128 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) );
1129
1130 // Check required parameters exist.
1131 if ( empty( $this->access_token ) ) {
1132 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Error: No access token was specified' );
1133 return new \WP_Error( 'missing_access_token', __( 'No access token was specified', 'wp-to-buffer' ) );
1134 }
1135
1136 // Fetch a new access token and refresh token.
1137 $result = $this->refresh_token();
1138
1139 // Bail if something went wrong.
1140 if ( is_wp_error( $result ) ) {
1141 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): Error: ' . $result->get_error_message() );
1142 return $result;
1143 }
1144
1145 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): access_token = ' . $this->access_token );
1146 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): refresh_token = ' . $this->refresh_token );
1147 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . $this->token_expires );
1148 $this->base->get_class( 'log' )->add_to_debug_log( 'Buffer API: graphql_query(): token_expires = ' . ( $this->token_expires && time() > (int) $this->token_expires ? 'expired ' . ( time() - (int) $this->token_expires ) . 's ago' : 'expires in ' . ( (int) $this->token_expires - time() ) . 's' ) );
1149
1150 // Build body.
1151 $body = array( 'query' => $query );
1152 if ( ! empty( $variables ) ) {
1153 $body['variables'] = $variables;
1154 }
1155
1156 // Send request.
1157 $result = wp_remote_post(
1158 $this->api_endpoint,
1159 array(
1160 'headers' => $this->get_request_headers( 'application/json' ),
1161 'body' => $this->get_body( $body, 'application/json' ),
1162 'timeout' => $this->get_timeout(),
1163 'user-agent' => $this->get_user_agent(),
1164 )
1165 );
1166
1167 // If an error occured, return it now.
1168 if ( is_wp_error( $result ) ) {
1169 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: graphql_query(): Error: [%s] %s', $result->get_error_code(), $result->get_error_message() ) );
1170 return $result;
1171 }
1172
1173 // Parse the response.
1174 $response = $this->parse_response( $result );
1175
1176 // Log any API error, so failures that would otherwise be silent are captured.
1177 if ( is_wp_error( $response ) ) {
1178 $this->base->get_class( 'log' )->add_to_debug_log( sprintf( 'Buffer API: graphql_query(): API returned error: [%s] %s', $response->get_error_code(), $response->get_error_message() ) );
1179 }
1180
1181 return $response;
1182
1183 }
1184
1185 /**
1186 * Returns the headers to use in an authenticated GraphQL API request.
1187 *
1188 * @param string $type Accept and Content-Type Headers.
1189 *
1190 * @since 6.0.0
1191 *
1192 * @return array
1193 */
1194 private function get_request_headers( $type = 'application/json' ) {
1195
1196 $headers = array(
1197 'Accept' => 'application/json',
1198 'Content-Type' => $type,
1199 );
1200
1201 // Add authorization header and return.
1202 if ( $this->access_token ) {
1203 $headers['Authorization'] = 'Bearer ' . $this->access_token;
1204 }
1205
1206 return $headers;
1207
1208 }
1209
1210 /**
1211 * Returns the body to use in an API request.
1212 *
1213 * @param array $params Parameters.
1214 * @param string $content_type Content Type.
1215 *
1216 * @since 6.0.0
1217 *
1218 * @return string
1219 */
1220 private function get_body( $params = array(), $content_type = 'application/json' ) {
1221
1222 return ( $content_type === 'application/x-www-form-urlencoded' ? http_build_query( $params ) : wp_json_encode( $params ) );
1223
1224 }
1225
1226 /**
1227 * Returns the maximum amount of time to wait for
1228 * a response to the request before exiting.
1229 *
1230 * @since 1.0.0
1231 *
1232 * @return int Timeout, in seconds.
1233 */
1234 private function get_timeout() {
1235
1236 $timeout = 10;
1237
1238 /**
1239 * Defines the maximum time to allow the API request to run.
1240 *
1241 * @since 1.0.0
1242 *
1243 * @param int $timeout Timeout, in seconds.
1244 */
1245 $timeout = apply_filters( $this->base->plugin->filter_name . '_pro_api_get_timeout', $timeout );
1246
1247 return $timeout;
1248
1249 }
1250
1251 /**
1252 * Gets a customized version of the WordPress default user agent.
1253 *
1254 * @since 6.0.0
1255 *
1256 * @return string User Agent
1257 */
1258 private function get_user_agent() {
1259
1260 return sprintf(
1261 '%1$s/%2$s (WordPress/%3$s; PHP/%4$s)',
1262 $this->base->plugin->name,
1263 $this->base->plugin->version,
1264 get_bloginfo( 'version' ),
1265 PHP_VERSION
1266 );
1267
1268 }
1269
1270 /**
1271 * Parses the response body, returning a \WP_Error
1272 * if the response body contains an error.
1273 *
1274 * @since 3.9.8
1275 *
1276 * @param array|\WP_Error $response HTTP Response.
1277 * @return \WP_Error|array
1278 */
1279 private function parse_response( $response ) {
1280
1281 // Get HTTP code and body.
1282 $http_code = wp_remote_retrieve_response_code( $response );
1283 $http_body = wp_remote_retrieve_body( $response );
1284
1285 // Handle HTTP errors.
1286 switch ( $http_code ) {
1287 case 403:
1288 return new \WP_Error(
1289 'buffer_api_error',
1290 '403 Forbidden'
1291 );
1292 }
1293
1294 // Retain-and-retry on server errors: return without touching stored tokens.
1295 if ( $http_code >= 500 ) {
1296 return new \WP_Error( 'buffer_api_server_error', $http_code . ' server error' );
1297 }
1298
1299 // Decode response.
1300 $body = json_decode( $http_body, true );
1301
1302 // Bail if the response isn't valid JSON.
1303 if ( ! is_array( $body ) ) {
1304 return new \WP_Error( 'buffer_api_invalid_response', 'Invalid API response' );
1305 }
1306
1307 // If an error is detected, return it.
1308 if ( array_key_exists( 'error', $body ) ) {
1309 return new \WP_Error(
1310 $body['error'],
1311 $body['error_description']
1312 );
1313 }
1314
1315 // If multiple errors are detected, return them.
1316 if ( array_key_exists( 'errors', $body ) ) {
1317 // If the access token begins with '2/', it's from the old API.
1318 if ( strpos( $this->access_token, '2/' ) === 0 ) {
1319 return new \WP_Error(
1320 'buffer_api_error',
1321 sprintf(
1322 /* translators: %1$s: Plugin Name, %2$s: Plugin Name */
1323 __( '%1$s uses a new API. Please click the `Reconnect` button at %2$s Settings > Authentication to reconnect your account. You won\'t need to do this again.', 'wp-to-buffer' ),
1324 $this->base->plugin->displayName,
1325 $this->base->plugin->displayName
1326 )
1327 );
1328 }
1329
1330 return new \WP_Error(
1331 $body['errors'][0]['extensions']['code'],
1332 $body['errors'][0]['message']
1333 );
1334 }
1335
1336 // Check for mutation-level errors inside data.
1337 // Mutation responses are nested under data.{operationName}.
1338 // Success responses contain specific keys (e.g. 'post', 'idea').
1339 // Error responses contain only 'message'.
1340 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1341 foreach ( $body['data'] as $operation => $result ) {
1342 if ( is_array( $result )
1343 && isset( $result['message'] )
1344 && count( $result ) === 1
1345 ) {
1346 return new \WP_Error(
1347 'buffer_api_error',
1348 $this->provide_verbose_error_message( $result['message'] )
1349 );
1350 }
1351 }
1352 }
1353
1354 return $body;
1355
1356 }
1357
1358 /**
1359 * Provides a more actionable error message, based on the error message supplied
1360 * from the Buffer API MutationError.
1361 *
1362 * @since 6.0.0
1363 *
1364 * @param string $message Error Message.
1365 * @return string
1366 */
1367 private function provide_verbose_error_message( $message ) {
1368
1369 switch ( $message ) {
1370 case 'Invalid post: First comment requires a paid plan. Please upgrade to use this feature.':
1371 return 'Your buffer.com plan does not support first comments. Please upgrade to a paid plan on buffer.com to use this feature.';
1372
1373 default:
1374 return $message;
1375 }
1376
1377 }
1378
1379 }
1380