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

1,343 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 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 $assets[] = array(
968 'image' => array(
969 'url' => $media['image'],
970 'thumbnailUrl' => $media['thumbnail'],
971 'metadata' => array(
972 'altText' => $media['alt_text'],
973 'dimensions' => array(
974 'width' => $media['width'],
975 'height' => $media['height'],
976 ),
977 ),
978 ),
979 );
980 }
981 break;
982 }
983
984 // Include assets. Always overwrites the default empty array
985 // initialised above, if the service branch built any.
986 if ( ! empty( $assets ) ) {
987 $variables['assets'] = $assets;
988 }
989
990 // Build GraphQL query.
991 $query = '
992 mutation CreatePost(
993 $channelId: ChannelId!
994 $text: String
995 $schedulingType: SchedulingType!
996 $mode: ShareMode!
997 $source: String
998 $dueAt: DateTime
999 $saveToDraft: Boolean
1000 $assets: [AssetInput!]!
1001 $metadata: PostInputMetaData
1002 ) {
1003 createPost(input: {
1004 channelId: $channelId
1005 text: $text
1006 schedulingType: $schedulingType
1007 mode: $mode
1008 source: $source
1009 dueAt: $dueAt
1010 saveToDraft: $saveToDraft
1011 assets: $assets
1012 metadata: $metadata
1013 }) {
1014 ... on PostActionSuccess {
1015 post {
1016 id
1017 text
1018 status
1019 dueAt
1020 }
1021 }
1022 ... on MutationError {
1023 message
1024 }
1025 }
1026 }';
1027
1028 // Send update.
1029 $result = $this->graphql_query( $query, $variables );
1030
1031 // Bail if the result is an error.
1032 if ( is_wp_error( $result ) ) {
1033 return $result;
1034 }
1035
1036 // Return array of just the data we need to send to the Plugin.
1037 return array(
1038 'profile_id' => $params['profile_ids'][0], // API doesn't return the Profile ID.
1039 'message' => $result['data']['createPost']['post']['status'],
1040 'status_text' => $result['data']['createPost']['post']['text'],
1041
1042 // Both must be UTC timestamps.
1043 'status_created_at' => strtotime( 'now' ),
1044 // due_at won't exist if is_draft = 'true' when the update was created.
1045 'due_at' => ( isset( $result['data']['createPost']['post']['dueAt'] ) ? strtotime( $result['data']['createPost']['post']['dueAt'] ) : '0000-00-00 00:00:00' ),
1046 );
1047
1048 }
1049
1050 /**
1051 * Main function for fetching access tokens and refreshing existing tokens.
1052 *
1053 * All requests are sent using POST, as the Buffer API uses GraphQL:
1054 * https://developers.buffer.com/guides/rest-migration.html
1055 *
1056 * @since 3.0.0
1057 *
1058 * @param string $url URL.
1059 * @param array $params Parameters (optional).
1060 * @return WP_Error|array
1061 */
1062 private function oauth_request( $url, $params = array() ) {
1063
1064 // Send request.
1065 $result = wp_remote_post(
1066 $url,
1067 array(
1068 'headers' => array(
1069 'Accept' => 'application/json',
1070 'Content-Type' => 'application/x-www-form-urlencoded',
1071 ),
1072 'body' => $this->get_body( $params, 'application/x-www-form-urlencoded' ),
1073 'timeout' => $this->get_timeout(),
1074 'user-agent' => $this->get_user_agent(),
1075 )
1076 );
1077
1078 // If an error occured, return it now.
1079 if ( is_wp_error( $result ) ) {
1080 return $result;
1081 }
1082
1083 // Parse response and return.
1084 return $this->parse_response( $result );
1085
1086 }
1087
1088 /**
1089 * Main function which handles sending requests to Buffer API's
1090 * GraphQL endpoints.
1091 *
1092 * All requests are sent using POST, as the Buffer API uses GraphQL:
1093 * https://developers.buffer.com/guides/rest-migration.html
1094 *
1095 * @since 6.0.0
1096 *
1097 * @param string $query GraphQL Query.
1098 * @param array $variables GraphQL Variables.
1099 * @param bool $is_retry Whether this is a retry following a token refresh.
1100 * @return WP_Error|array
1101 */
1102 private function graphql_query( $query, $variables = array(), $is_retry = false ) {
1103
1104 // Build body.
1105 $body = array( 'query' => $query );
1106 if ( ! empty( $variables ) ) {
1107 $body['variables'] = $variables;
1108 }
1109
1110 // Send request.
1111 $result = wp_remote_post(
1112 $this->api_endpoint,
1113 array(
1114 'headers' => $this->get_request_headers( 'application/json' ),
1115 'body' => $this->get_body( $body, 'application/json' ),
1116 'timeout' => $this->get_timeout(),
1117 'user-agent' => $this->get_user_agent(),
1118 )
1119 );
1120
1121 // If an error occured, return it now.
1122 if ( is_wp_error( $result ) ) {
1123 return $result;
1124 }
1125
1126 // Parse result.
1127 $response = $this->parse_response( $result );
1128
1129 // If this is a retry, return the parsed response.
1130 // This prevents an infinite loop of retries when a token refresh fails.
1131 if ( $is_retry ) {
1132 return $response;
1133 }
1134
1135 // If the request was successful, return the response.
1136 if ( ! is_wp_error( $response ) ) {
1137 return $response;
1138 }
1139
1140 // If the error isn't an unauthenticated error, return it.
1141 if ( strtolower( $response->get_error_code() ) !== 'unauthenticated' ) {
1142 return $response;
1143 }
1144
1145 // Attempt to refresh the token.
1146 $refresh_result = $this->refresh_token();
1147
1148 // Bail if the refresh token attempt failed.
1149 if ( is_wp_error( $refresh_result ) ) {
1150 return $refresh_result;
1151 }
1152
1153 // Attempt the request again, now we have a new access token.
1154 return $this->graphql_query( $query, $variables, true );
1155
1156 }
1157
1158 /**
1159 * Returns the headers to use in an authenticated GraphQL API request.
1160 *
1161 * @param string $type Accept and Content-Type Headers.
1162 *
1163 * @since 6.0.0
1164 *
1165 * @return array
1166 */
1167 private function get_request_headers( $type = 'application/json' ) {
1168
1169 $headers = array(
1170 'Accept' => 'application/json',
1171 'Content-Type' => $type,
1172 );
1173
1174 // Add authorization header and return.
1175 if ( $this->access_token ) {
1176 $headers['Authorization'] = 'Bearer ' . $this->access_token;
1177 }
1178
1179 return $headers;
1180
1181 }
1182
1183 /**
1184 * Returns the body to use in an API request.
1185 *
1186 * @param array $params Parameters.
1187 * @param string $content_type Content Type.
1188 *
1189 * @since 6.0.0
1190 *
1191 * @return string
1192 */
1193 private function get_body( $params = array(), $content_type = 'application/json' ) {
1194
1195 return ( $content_type === 'application/x-www-form-urlencoded' ? http_build_query( $params ) : wp_json_encode( $params ) );
1196
1197 }
1198
1199 /**
1200 * Returns the maximum amount of time to wait for
1201 * a response to the request before exiting.
1202 *
1203 * @since 1.0.0
1204 *
1205 * @return int Timeout, in seconds.
1206 */
1207 private function get_timeout() {
1208
1209 $timeout = 10;
1210
1211 /**
1212 * Defines the maximum time to allow the API request to run.
1213 *
1214 * @since 1.0.0
1215 *
1216 * @param int $timeout Timeout, in seconds.
1217 */
1218 $timeout = apply_filters( $this->base->plugin->filter_name . '_pro_api_get_timeout', $timeout );
1219
1220 return $timeout;
1221
1222 }
1223
1224 /**
1225 * Gets a customized version of the WordPress default user agent.
1226 *
1227 * @since 6.0.0
1228 *
1229 * @return string User Agent
1230 */
1231 private function get_user_agent() {
1232
1233 return sprintf(
1234 '%1$s/%2$s (WordPress/%3$s; PHP/%4$s)',
1235 $this->base->plugin->name,
1236 $this->base->plugin->version,
1237 get_bloginfo( 'version' ),
1238 PHP_VERSION
1239 );
1240
1241 }
1242
1243 /**
1244 * Parses the response body, returning a WP_Error
1245 * if the response body contains an error.
1246 *
1247 * @since 3.9.8
1248 *
1249 * @param string $response Response Body.
1250 * @return WP_Error|array
1251 */
1252 private function parse_response( $response ) {
1253
1254 // Get HTTP code and body.
1255 $http_code = wp_remote_retrieve_response_code( $response );
1256 $http_body = wp_remote_retrieve_body( $response );
1257
1258 // Handle HTTP errors.
1259 switch ( $http_code ) {
1260 case 403:
1261 return new \WP_Error(
1262 'buffer_api_error',
1263 '403 Forbidden'
1264 );
1265 }
1266
1267 // Decode response.
1268 $body = json_decode( $http_body, true );
1269
1270 // If an error is detected, return it.
1271 if ( array_key_exists( 'error', $body ) ) {
1272 return new \WP_Error(
1273 $body['error'],
1274 $body['error_description']
1275 );
1276 }
1277
1278 // If multiple errors are detected, return them.
1279 if ( array_key_exists( 'errors', $body ) ) {
1280 // If the access token begins with '2/', it's from the old API.
1281 if ( strpos( $this->access_token, '2/' ) === 0 ) {
1282 return new \WP_Error(
1283 'buffer_api_error',
1284 sprintf(
1285 /* translators: %1$s: Plugin Name, %2$s: Plugin Name */
1286 __( '%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' ),
1287 $this->base->plugin->displayName,
1288 $this->base->plugin->displayName
1289 )
1290 );
1291 }
1292
1293 return new \WP_Error(
1294 $body['errors'][0]['extensions']['code'],
1295 $body['errors'][0]['message']
1296 );
1297 }
1298
1299 // Check for mutation-level errors inside data.
1300 // Mutation responses are nested under data.{operationName}.
1301 // Success responses contain specific keys (e.g. 'post', 'idea').
1302 // Error responses contain only 'message'.
1303 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1304 foreach ( $body['data'] as $operation => $result ) {
1305 if ( is_array( $result )
1306 && isset( $result['message'] )
1307 && count( $result ) === 1
1308 ) {
1309 return new \WP_Error(
1310 'buffer_api_error',
1311 $this->provide_verbose_error_message( $result['message'] )
1312 );
1313 }
1314 }
1315 }
1316
1317 return $body;
1318
1319 }
1320
1321 /**
1322 * Provides a more actionable error message, based on the error message supplied
1323 * from the Buffer API MutationError.
1324 *
1325 * @since 6.0.0
1326 *
1327 * @param string $message Error Message.
1328 * @return string
1329 */
1330 private function provide_verbose_error_message( $message ) {
1331
1332 switch ( $message ) {
1333 case 'Invalid post: First comment requires a paid plan. Please upgrade to use this feature.':
1334 return 'Your buffer.com plan does not support first comments. Please upgrade to a paid plan on buffer.com to use this feature.';
1335
1336 default:
1337 return $message;
1338 }
1339
1340 }
1341
1342 }
1343