PluginProbe
Social Media Auto Poster – Schedule & Publish to Buffer / 6.2.3
Social Media Auto Poster – Schedule & Publish to Buffer v6.2.3
6.2.5 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 All 126 releases
wp-to-buffer / includes / class-buffer-api.php

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

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