PluginProbe
Social Media Auto Poster – Schedule & Publish to Buffer / 6.2.2
Social Media Auto Poster – Schedule & Publish to Buffer v6.2.2
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 6.2.2, at includes/class-buffer-api.php

1,339 lines 33.1 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|false
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 a 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->access_token = $access_token;
336 $this->refresh_token = $refresh_token;
337 $this->token_expires = $token_expires;
338
339 }
340
341 /**
342 * Exchanges the given code for an access token, refresh token and other data.
343 *
344 * @since 6.0.0
345 *
346 * @param string $authorization_code Authorization Code, returned from get_oauth_url() flow.
347 * @return \WP_Error|array
348 */
349 public function get_access_token( $authorization_code ) {
350
351 $result = $this->oauth_request(
352 $this->oauth_authorize_url . 'token',
353 array(
354 'client_id' => $this->client_id,
355 'grant_type' => 'authorization_code',
356 'code' => $authorization_code,
357 'redirect_uri' => $this->redirect_uri,
358 'code_verifier' => $this->get_code_verifier(),
359 )
360 );
361
362 // Delete code verifier, as it's no longer needed.
363 // If the access token request fails, the user
364 // will begin the process again, which generates a
365 // new code verifier.
366 $this->delete_code_verifier();
367
368 // If an error occured, return it now.
369 if ( is_wp_error( $result ) ) {
370 return $result;
371 }
372
373 // Update the access and refresh tokens in this class.
374 $this->set_tokens( $result['access_token'], $result['refresh_token'], strtotime( '+' . $result['expires_in'] . ' seconds' ) );
375
376 // Return data.
377 return array(
378 'access_token' => $this->access_token,
379 'refresh_token' => $this->refresh_token,
380 'token_expires' => $this->token_expires,
381 );
382
383 }
384
385 /**
386 * Fetches a new access token using the supplied refresh token.
387 *
388 * @since 2.0.0
389 */
390 public function refresh_token() {
391
392 // Bail if no refresh token is available to use, otherwise we'll
393 // send a request to Buffer with an empty refresh_token, which
394 // will fail.
395 if ( empty( $this->refresh_token ) ) {
396 return new \WP_Error(
397 $this->base->plugin->filter_name . '_api_refresh_token_error',
398 __( 'No refresh token available; cannot refresh access token.', 'wp-to-buffer' )
399 );
400 }
401
402 $result = $this->oauth_request(
403 $this->oauth_authorize_url . 'token',
404 array(
405 'client_id' => $this->client_id,
406 'grant_type' => 'refresh_token',
407 'refresh_token' => $this->refresh_token,
408 )
409 );
410
411 // If an error occured, log and return it now.
412 if ( is_wp_error( $result ) ) {
413 /**
414 * Perform any actions when refreshing an expired access token fails.
415 *
416 * @since 6.0.0
417 *
418 * @param \WP_Error $result Error from API.
419 * @param string $client_id OAuth Client ID.
420 * @param string $access_token Access Token.
421 * @param string $refresh_token Refresh Token.
422 */
423 do_action( $this->base->plugin->filter_name . '_api_refresh_token_error', $result, $this->client_id, $this->access_token, $this->refresh_token );
424
425 return $result;
426 }
427
428 // Build result data.
429 $result = array(
430 'access_token' => $result['access_token'],
431 'refresh_token' => $result['refresh_token'],
432 'token_expires' => strtotime( '+' . $result['expires_in'] . ' seconds' ),
433 );
434
435 /**
436 * Perform any actions with the new access token, such as saving it.
437 *
438 * @since 6.0.0
439 *
440 * @param array $result New Access Token, Refresh Token and Expiry timestamp.
441 * @param string $client_id OAuth Client ID.
442 * @param string $previous_access_token Existing Access Token.
443 * @param string $previous_refresh_token Existing Refresh Token.
444 */
445 do_action( $this->base->plugin->filter_name . '_api_refresh_token', $result, $this->client_id, $this->access_token, $this->refresh_token );
446
447 // Update the access and refresh tokens in this class.
448 $this->set_tokens( $result['access_token'], $result['refresh_token'], $result['token_expires'] );
449
450 // Return new access token, refresh token and expiry timestamp.
451 return $result;
452
453 }
454
455 /**
456 * Returns the organizations the current account is a member of.
457 *
458 * @since 6.0.1
459 *
460 * @param bool $force Force API call (false = use stored option).
461 *
462 * @return \WP_Error|array
463 */
464 public function organizations( $force = false ) {
465
466 // Return stored organizations from the non-autoloaded option, unless
467 // forcing a refresh from the API.
468 $option_name = $this->base->plugin->name . '-organizations';
469 $organizations = get_option( $option_name );
470 if ( $force || ! is_array( $organizations ) ) {
471 // Build GraphQL query.
472 $query = '
473 query {
474 account {
475 organizations {
476 id
477 name
478 ownerEmail
479 limits {
480 channels
481 scheduledPosts
482 }
483 }
484 }
485 }';
486
487 // Query API.
488 $result = $this->graphql_query( $query );
489
490 // Bail if an error occurred.
491 if ( is_wp_error( $result ) ) {
492 return $result;
493 }
494
495 // Build the organizations array. Reset to an empty array first, as
496 // get_option() returns false when the option does not exist.
497 $organizations = array();
498
499 foreach ( $result['data']['account']['organizations'] as $organization ) {
500 $organizations[ $organization['id'] ] = array(
501 'id' => $organization['id'],
502 'name' => $organization['name'],
503 'email' => $organization['ownerEmail'],
504 'channel_limit' => $organization['limits']['channels'],
505 'plan' => $organization['limits']['scheduledPosts'] === 10 ? 'free' : 'paid',
506 );
507 }
508
509 // Store organizations in a non-autoloaded option so they persist
510 // across object cache eviction, and don't load on every WP request.
511 update_option( $option_name, $organizations, false );
512 }
513
514 return $organizations;
515
516 }
517
518 /**
519 * Returns the account within the organization with the given ID.
520 *
521 * @since 6.0.0
522 *
523 * @param string $account_id Account ID.
524 * @return \WP_Error|array
525 */
526 public function account( $account_id = '' ) {
527
528 $organizations = $this->organizations( false );
529
530 // Bail if an error occurred.
531 if ( is_wp_error( $organizations ) ) {
532 return $organizations;
533 }
534
535 // Bail if the organization is not found.
536 if ( ! array_key_exists( $account_id, $organizations ) ) {
537 return new \WP_Error( 'organization_not_found', __( 'Organization not found.', 'wp-to-buffer' ) );
538 }
539
540 return $organizations[ $account_id ];
541
542 }
543
544 /**
545 * Returns a list of Social Media Profiles attached to the Buffer Account.
546 *
547 * Profiles are cached in a non-autoloaded option. Persistence survives
548 * object cache eviction. Callers force a refresh via the Refresh Profiles
549 * button in Settings > Authentication.
550 *
551 * @since 3.0.0
552 *
553 * @param bool $force Force API call (false = use stored option).
554 * @param string $account_id Account ID.
555 * @return \WP_Error|array
556 */
557 public function profiles( $force = false, $account_id = 'default' ) {
558
559 // Return stored profiles if available and not forcing a refresh.
560 $option_name = $this->base->plugin->name . '-profiles-' . $account_id;
561 $profiles = get_option( $option_name );
562 if ( ! $force && is_array( $profiles ) ) {
563 return $profiles;
564 }
565
566 // Build GraphQL query.
567 $query = '
568 query GetChannels($organizationId: OrganizationId!) {
569 channels(input: { organizationId: $organizationId }) {
570 id
571 descriptor
572 name
573 service
574 serviceId
575 timezone
576 metadata {
577 ... on PinterestMetadata {
578 boards {
579 id
580 serviceId
581 name
582 }
583 }
584 }
585 }
586 }';
587
588 // Get profiles.
589 $results = $this->graphql_query(
590 $query,
591 array(
592 'organizationId' => $account_id,
593 )
594 );
595
596 // Check for errors.
597 if ( is_wp_error( $results ) ) {
598 return $results;
599 }
600
601 // Build profiles array from results.
602 $profiles = array();
603 foreach ( $results['data']['channels'] as $channel ) {
604 $profiles[ $channel['id'] ] = array(
605 'id' => $channel['id'], // Buffer ID. Buffer uses this for the ID when creating a post.
606 'formatted_service' => $channel['descriptor'],
607 'formatted_username' => $channel['name'],
608 'service' => $channel['service'],
609 'timezone' => $channel['timezone'],
610 'can_be_subprofile' => false, // For pinterest, the profile is the account, not the board.
611 );
612
613 // Pinterest: Add Boards as Subprofiles.
614 switch ( $channel['service'] ) {
615 case 'pinterest':
616 $profiles[ $channel['id'] ]['subprofiles'] = array();
617 foreach ( $channel['metadata']['boards'] as $board ) {
618 $profiles[ $channel['id'] ]['subprofiles'][ $board['serviceId'] ] = array(
619 'id' => $board['serviceId'], // Social Network (Pinterest) ID. Buffer uses this for the ID when creating a Pin. Yes, it's different from ['id'] above.
620 'name' => $board['name'],
621 'service' => $channel['service'],
622 );
623 }
624 break;
625 }
626 }
627
628 // Store profiles in a non-autoloaded option so they persist across
629 // object cache eviction, and don't load on every WP request.
630 update_option( $option_name, $profiles, false );
631
632 return $profiles;
633
634 }
635
636 /**
637 * Returns a single post by its ID.
638 *
639 * @since 6.0.0
640 *
641 * @param string $post_id Post ID.
642 * @return \WP_Error|array
643 */
644 public function get_post( $post_id ) {
645
646 $query = '
647 query GetPost($postId: PostId!) {
648 post(input: { id: $postId }) {
649 id
650 status
651 text
652 metadata {
653 ... on FacebookPostMetadata {
654 annotations {
655 type
656 content
657 indices
658 text
659 url
660 }
661 }
662 }
663 }
664 }';
665
666 $result = $this->graphql_query(
667 $query,
668 array(
669 'postId' => $post_id,
670 )
671 );
672
673 if ( is_wp_error( $result ) ) {
674 return $result;
675 }
676
677 return $result['data']['post'];
678
679 }
680
681 /**
682 * Creates an update (status)
683 *
684 * @since 3.0.0
685 *
686 * @param array $params Params.
687 * @param string $service Service.
688 * @return \WP_Error|array
689 */
690 public function updates_create( $params, $service ) {
691
692 // Build GraphQL variables.
693 // assets defaults to an empty array; Buffer's schema requires a
694 // non-null list even for text-only posts, so we always send this key.
695 $variables = array(
696 'channelId' => $params['profile_ids'][0],
697 'text' => $params['text'],
698 'schedulingType' => 'automatic',
699 'mode' => 'addToQueue',
700 'source' => 'wp-buffer',
701 'assets' => array(),
702 );
703 $assets = array();
704
705 // Scheduling.
706 switch ( $params['schedule_type'] ) {
707 case 'queue_end':
708 $variables['mode'] = 'addToQueue';
709 break;
710
711 case 'queue_start':
712 $variables['mode'] = 'shareNext';
713 break;
714
715 case 'immediate':
716 $variables['mode'] = 'shareNow';
717 break;
718
719 default:
720 // If no scheduled_at is set, add to end of queue.
721 if ( ! array_key_exists( 'scheduled_at', $params ) ) {
722 $variables['mode'] = 'addToQueue';
723 break;
724 }
725 if ( empty( $params['scheduled_at'] ) ) {
726 $variables['mode'] = 'addToQueue';
727 break;
728 }
729
730 $variables['mode'] = 'customScheduled';
731 $variables['dueAt'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $params['scheduled_at'] ) );
732 break;
733 }
734
735 // Draft.
736 if ( array_key_exists( 'is_draft', $params ) ) {
737 $variables['saveToDraft'] = (bool) $params['is_draft'];
738 }
739
740 // Metadata.
741 $metadata = array();
742 switch ( $service ) {
743
744 case 'instagram':
745 $metadata = array(
746 'type' => $params['post_type'] === 'story' ? 'story' : 'post',
747 'shouldShareToFeed' => true,
748 );
749
750 // First Comment.
751 if ( ! empty( $params['first_comment'] ) ) {
752 $metadata['firstComment'] = $params['first_comment'];
753 }
754 break;
755
756 case 'facebook':
757 $metadata = array(
758 'type' => 'post',
759 );
760
761 // First Comment.
762 if ( ! empty( $params['first_comment'] ) ) {
763 $metadata['firstComment'] = $params['first_comment'];
764 }
765
766 // OpenGraph / Link Attachment.
767 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
768 $assets = array(
769 array(
770 'link' => array(
771 'url' => $params['url'],
772 ),
773 ),
774 );
775 }
776
777 // Annotations.
778 if ( array_key_exists( 'annotations', $params ) ) {
779 $metadata['annotations'] = $params['annotations'];
780 }
781 break;
782
783 case 'linkedin':
784 // First Comment.
785 if ( ! empty( $params['first_comment'] ) ) {
786 $metadata['firstComment'] = $params['first_comment'];
787 }
788
789 // OpenGraph / Link Attachment.
790 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
791 $assets = array(
792 array(
793 'link' => array(
794 'url' => $params['url'],
795 ),
796 ),
797 );
798 }
799
800 // Annotations.
801 if ( array_key_exists( 'annotations', $params ) ) {
802 $metadata['annotations'] = $params['annotations'];
803 }
804 break;
805
806 case 'twitter':
807 // First Comment.
808 if ( ! empty( $params['first_comment'] ) ) {
809 $metadata = array(
810 'thread' => array(
811 array(
812 'text' => $params['first_comment'],
813 ),
814 ),
815 );
816 }
817
818 // OpenGraph / Link Attachment.
819 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
820 $variables['text'] .= ' ' . $params['url'];
821 }
822 break;
823
824 case 'pinterest':
825 $metadata = array(
826 'title' => $params[ $service ]['title'],
827 'url' => $params['url'],
828 'boardServiceId' => $params[ $service ]['board'],
829 );
830 break;
831
832 case 'googlebusiness':
833 // Post Type.
834 $metadata = array(
835 'type' => $params[ $service ]['post_type'],
836 );
837
838 // Depending on the Post Type, add the appropriate metadata.
839 switch ( $metadata['type'] ) {
840 case 'whats_new':
841 $metadata['detailsWhatsNew'] = array(
842 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
843 'link' => $params['url'],
844 );
845 break;
846 case 'event':
847 $metadata['detailsEvent'] = array(
848 'title' => $params[ $service ]['title'],
849 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
850 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
851 'isFullDayEvent' => false,
852 'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
853 'link' => $params['url'],
854 );
855 break;
856
857 case 'offer':
858 $metadata['detailsOffer'] = array(
859 'title' => $params[ $service ]['title'],
860 'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
861 'endDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
862 'code' => $params[ $service ]['code'],
863 'link' => $params['url'],
864 'terms' => $params[ $service ]['terms'],
865 );
866 break;
867 }
868 break;
869
870 case 'threads':
871 // OpenGraph / Link Attachment.
872 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
873 $assets = array(
874 array(
875 'link' => array(
876 'url' => $params['url'],
877 ),
878 ),
879 );
880 }
881
882 // First Comment.
883 if ( ! empty( $params['first_comment'] ) ) {
884 $metadata = array(
885 'thread' => array(
886 array(
887 'text' => $params['first_comment'],
888 ),
889 ),
890 );
891 }
892 break;
893
894 case 'bluesky':
895 // OpenGraph / Link Attachment.
896 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
897 $assets = array(
898 array(
899 'link' => array(
900 'url' => $params['url'],
901 ),
902 ),
903 );
904 }
905
906 // First Comment.
907 if ( ! empty( $params['first_comment'] ) ) {
908 $metadata = array(
909 'thread' => array(
910 array(
911 'text' => $params['first_comment'],
912 ),
913 ),
914 );
915 }
916 break;
917
918 case 'mastodon':
919 // First Comment.
920 if ( ! empty( $params['first_comment'] ) ) {
921 $metadata = array(
922 'thread' => array(
923 array(
924 'text' => $params['first_comment'],
925 ),
926 ),
927 );
928 }
929
930 // OpenGraph / Link Attachment.
931 if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
932 $variables['text'] .= ' ' . $params['url'];
933 }
934 break;
935 }
936
937 // Add metadata.
938 if ( ! empty( $metadata ) ) {
939 $variables['metadata'] = array(
940 ( $service === 'googlebusiness' ? 'google' : $service ) => $metadata,
941 );
942 }
943
944 // Assets / Images.
945 switch ( $params['post_type'] ) {
946 case 'image':
947 case 'story':
948 case 'pin':
949 case 'googlebusiness':
950 // Bail if no images are defined.
951 if ( ! array_key_exists( 'media_urls', $params ) ) {
952 break;
953 }
954
955 // Build assets array.
956 $assets = array();
957 foreach ( $params['media_urls'] as $media ) {
958 // Skip anything that isn't an image, so we never send null asset data.
959 if ( ! is_array( $media ) || empty( $media['image'] ) ) {
960 continue;
961 }
962
963 $assets[] = array(
964 'image' => array(
965 'url' => $media['image'],
966 'thumbnailUrl' => $media['thumbnail'],
967 'metadata' => array(
968 'altText' => $media['alt_text'],
969 'dimensions' => array(
970 'width' => $media['width'],
971 'height' => $media['height'],
972 ),
973 ),
974 ),
975 );
976 }
977 break;
978 }
979
980 // Include assets. Always overwrites the default empty array
981 // initialised above, if the service branch built any.
982 if ( ! empty( $assets ) ) {
983 $variables['assets'] = $assets;
984 }
985
986 // Build GraphQL query.
987 $query = '
988 mutation CreatePost(
989 $channelId: ChannelId!
990 $text: String
991 $schedulingType: SchedulingType!
992 $mode: ShareMode!
993 $source: String
994 $dueAt: DateTime
995 $saveToDraft: Boolean
996 $assets: [AssetInput!]!
997 $metadata: PostInputMetaData
998 ) {
999 createPost(input: {
1000 channelId: $channelId
1001 text: $text
1002 schedulingType: $schedulingType
1003 mode: $mode
1004 source: $source
1005 dueAt: $dueAt
1006 saveToDraft: $saveToDraft
1007 assets: $assets
1008 metadata: $metadata
1009 }) {
1010 ... on PostActionSuccess {
1011 post {
1012 id
1013 text
1014 status
1015 dueAt
1016 }
1017 }
1018 ... on MutationError {
1019 message
1020 }
1021 }
1022 }';
1023
1024 // Send update.
1025 $result = $this->graphql_query( $query, $variables );
1026
1027 // Bail if the result is an error.
1028 if ( is_wp_error( $result ) ) {
1029 return $result;
1030 }
1031
1032 // Return array of just the data we need to send to the Plugin.
1033 return array(
1034 'profile_id' => $params['profile_ids'][0], // API doesn't return the Profile ID.
1035 'message' => $result['data']['createPost']['post']['status'],
1036 'status_text' => $result['data']['createPost']['post']['text'],
1037
1038 // Both must be UTC timestamps.
1039 'status_created_at' => strtotime( 'now' ),
1040 // due_at won't exist if is_draft = 'true' when the update was created.
1041 'due_at' => ( isset( $result['data']['createPost']['post']['dueAt'] ) ? strtotime( $result['data']['createPost']['post']['dueAt'] ) : '0000-00-00 00:00:00' ),
1042 );
1043
1044 }
1045
1046 /**
1047 * Main function for fetching access tokens and refreshing existing tokens.
1048 *
1049 * All requests are sent using POST, as the Buffer API uses GraphQL:
1050 * https://developers.buffer.com/guides/rest-migration.html
1051 *
1052 * @since 3.0.0
1053 *
1054 * @param string $url URL.
1055 * @param array $params Parameters (optional).
1056 * @return \WP_Error|array
1057 */
1058 private function oauth_request( $url, $params = array() ) {
1059
1060 // Send request.
1061 $result = wp_remote_post(
1062 $url,
1063 array(
1064 'headers' => array(
1065 'Accept' => 'application/json',
1066 'Content-Type' => 'application/x-www-form-urlencoded',
1067 ),
1068 'body' => $this->get_body( $params, 'application/x-www-form-urlencoded' ),
1069 'timeout' => $this->get_timeout(),
1070 'user-agent' => $this->get_user_agent(),
1071 )
1072 );
1073
1074 // If an error occured, return it now.
1075 if ( is_wp_error( $result ) ) {
1076 return $result;
1077 }
1078
1079 // Parse response and return.
1080 return $this->parse_response( $result );
1081
1082 }
1083
1084 /**
1085 * Main function which handles sending requests to Buffer API's
1086 * GraphQL endpoints.
1087 *
1088 * All requests are sent using POST, as the Buffer API uses GraphQL:
1089 * https://developers.buffer.com/guides/rest-migration.html
1090 *
1091 * @since 6.0.0
1092 *
1093 * @param string $query GraphQL Query.
1094 * @param array $variables GraphQL Variables.
1095 * @param bool $is_retry Whether this is a retry following a token refresh.
1096 * @return \WP_Error|array
1097 */
1098 private function graphql_query( $query, $variables = array(), $is_retry = false ) {
1099
1100 // Build body.
1101 $body = array( 'query' => $query );
1102 if ( ! empty( $variables ) ) {
1103 $body['variables'] = $variables;
1104 }
1105
1106 // Send request.
1107 $result = wp_remote_post(
1108 $this->api_endpoint,
1109 array(
1110 'headers' => $this->get_request_headers( 'application/json' ),
1111 'body' => $this->get_body( $body, 'application/json' ),
1112 'timeout' => $this->get_timeout(),
1113 'user-agent' => $this->get_user_agent(),
1114 )
1115 );
1116
1117 // If an error occured, return it now.
1118 if ( is_wp_error( $result ) ) {
1119 return $result;
1120 }
1121
1122 // Parse result.
1123 $response = $this->parse_response( $result );
1124
1125 // If this is a retry, return the parsed response.
1126 // This prevents an infinite loop of retries when a token refresh fails.
1127 if ( $is_retry ) {
1128 return $response;
1129 }
1130
1131 // If the request was successful, return the response.
1132 if ( ! is_wp_error( $response ) ) {
1133 return $response;
1134 }
1135
1136 // If the error isn't an unauthenticated error, return it.
1137 if ( strtolower( $response->get_error_code() ) !== 'unauthenticated' ) {
1138 return $response;
1139 }
1140
1141 // Attempt to refresh the token.
1142 $refresh_result = $this->refresh_token();
1143
1144 // Bail if the refresh token attempt failed.
1145 if ( is_wp_error( $refresh_result ) ) {
1146 return $refresh_result;
1147 }
1148
1149 // Attempt the request again, now we have a new access token.
1150 return $this->graphql_query( $query, $variables, true );
1151
1152 }
1153
1154 /**
1155 * Returns the headers to use in an authenticated GraphQL API request.
1156 *
1157 * @param string $type Accept and Content-Type Headers.
1158 *
1159 * @since 6.0.0
1160 *
1161 * @return array
1162 */
1163 private function get_request_headers( $type = 'application/json' ) {
1164
1165 $headers = array(
1166 'Accept' => 'application/json',
1167 'Content-Type' => $type,
1168 );
1169
1170 // Add authorization header and return.
1171 if ( $this->access_token ) {
1172 $headers['Authorization'] = 'Bearer ' . $this->access_token;
1173 }
1174
1175 return $headers;
1176
1177 }
1178
1179 /**
1180 * Returns the body to use in an API request.
1181 *
1182 * @param array $params Parameters.
1183 * @param string $content_type Content Type.
1184 *
1185 * @since 6.0.0
1186 *
1187 * @return string
1188 */
1189 private function get_body( $params = array(), $content_type = 'application/json' ) {
1190
1191 return ( $content_type === 'application/x-www-form-urlencoded' ? http_build_query( $params ) : wp_json_encode( $params ) );
1192
1193 }
1194
1195 /**
1196 * Returns the maximum amount of time to wait for
1197 * a response to the request before exiting.
1198 *
1199 * @since 1.0.0
1200 *
1201 * @return int Timeout, in seconds.
1202 */
1203 private function get_timeout() {
1204
1205 $timeout = 10;
1206
1207 /**
1208 * Defines the maximum time to allow the API request to run.
1209 *
1210 * @since 1.0.0
1211 *
1212 * @param int $timeout Timeout, in seconds.
1213 */
1214 $timeout = apply_filters( $this->base->plugin->filter_name . '_pro_api_get_timeout', $timeout );
1215
1216 return $timeout;
1217
1218 }
1219
1220 /**
1221 * Gets a customized version of the WordPress default user agent.
1222 *
1223 * @since 6.0.0
1224 *
1225 * @return string User Agent
1226 */
1227 private function get_user_agent() {
1228
1229 return sprintf(
1230 '%1$s/%2$s (WordPress/%3$s; PHP/%4$s)',
1231 $this->base->plugin->name,
1232 $this->base->plugin->version,
1233 get_bloginfo( 'version' ),
1234 PHP_VERSION
1235 );
1236
1237 }
1238
1239 /**
1240 * Parses the response body, returning a \WP_Error
1241 * if the response body contains an error.
1242 *
1243 * @since 3.9.8
1244 *
1245 * @param array|\WP_Error $response HTTP Response.
1246 * @return \WP_Error|array
1247 */
1248 private function parse_response( $response ) {
1249
1250 // Get HTTP code and body.
1251 $http_code = wp_remote_retrieve_response_code( $response );
1252 $http_body = wp_remote_retrieve_body( $response );
1253
1254 // Handle HTTP errors.
1255 switch ( $http_code ) {
1256 case 403:
1257 return new \WP_Error(
1258 'buffer_api_error',
1259 '403 Forbidden'
1260 );
1261 }
1262
1263 // Decode response.
1264 $body = json_decode( $http_body, true );
1265
1266 // If an error is detected, return it.
1267 if ( array_key_exists( 'error', $body ) ) {
1268 return new \WP_Error(
1269 $body['error'],
1270 $body['error_description']
1271 );
1272 }
1273
1274 // If multiple errors are detected, return them.
1275 if ( array_key_exists( 'errors', $body ) ) {
1276 // If the access token begins with '2/', it's from the old API.
1277 if ( strpos( $this->access_token, '2/' ) === 0 ) {
1278 return new \WP_Error(
1279 'buffer_api_error',
1280 sprintf(
1281 /* translators: %1$s: Plugin Name, %2$s: Plugin Name */
1282 __( '%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' ),
1283 $this->base->plugin->displayName,
1284 $this->base->plugin->displayName
1285 )
1286 );
1287 }
1288
1289 return new \WP_Error(
1290 $body['errors'][0]['extensions']['code'],
1291 $body['errors'][0]['message']
1292 );
1293 }
1294
1295 // Check for mutation-level errors inside data.
1296 // Mutation responses are nested under data.{operationName}.
1297 // Success responses contain specific keys (e.g. 'post', 'idea').
1298 // Error responses contain only 'message'.
1299 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1300 foreach ( $body['data'] as $operation => $result ) {
1301 if ( is_array( $result )
1302 && isset( $result['message'] )
1303 && count( $result ) === 1
1304 ) {
1305 return new \WP_Error(
1306 'buffer_api_error',
1307 $this->provide_verbose_error_message( $result['message'] )
1308 );
1309 }
1310 }
1311 }
1312
1313 return $body;
1314
1315 }
1316
1317 /**
1318 * Provides a more actionable error message, based on the error message supplied
1319 * from the Buffer API MutationError.
1320 *
1321 * @since 6.0.0
1322 *
1323 * @param string $message Error Message.
1324 * @return string
1325 */
1326 private function provide_verbose_error_message( $message ) {
1327
1328 switch ( $message ) {
1329 case 'Invalid post: First comment requires a paid plan. Please upgrade to use this feature.':
1330 return 'Your buffer.com plan does not support first comments. Please upgrade to a paid plan on buffer.com to use this feature.';
1331
1332 default:
1333 return $message;
1334 }
1335
1336 }
1337
1338 }
1339