PluginProbe
Social Media Auto Poster – Schedule & Publish to Buffer / 6.2.1
Social Media Auto Poster – Schedule & Publish to Buffer v6.2.1
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 3.8.8 All 124 releases
wp-to-buffer / includes / class-buffer-api.php

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

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