| | |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\integrations\Instance; |
| | | use JVBase\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\integrations\OAuth; |
| | | use JVBase\integrations\OAuthCredentials; |
| | |
| | | use JVBase\integrations\SyncFrom; |
| | | use JVBase\integrations\SyncTo; |
| | | use JVBase\integrations\UserConnection; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Sanitizer; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | $this->title = 'Facebook'; |
| | | $this->icon = 'facebook-logo'; |
| | | |
| | | $this->apiVersion = 'v25.0'; |
| | | $this->apiVersion = 'v26.0'; |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | 'update' => false, |
| | | 'delete' => false |
| | | ]; |
| | | |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | | $this->defineOAuth(); |
| | | $this->defineActions(); |
| | | $this->defineContentTypes(); |
| | | |
| | | $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion; |
| | | $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion; |
| | | |
| | | $this->supportsWebp = false; |
| | | |
| | | parent::__construct(); |
| | | } |
| | | |
| | |
| | | { |
| | | return 'Connect your Facebook Page to share posts, events, and engage with your audience.'; |
| | | } |
| | | |
| | | protected function defineContentTypes():void |
| | | { |
| | | $base = $this->getBaseFields(); |
| | | $this->contentTypes['post'] = array_merge($base, $this->contentTypePostFields()); |
| | | $this->contentTypes['image'] = array_merge($base, $this->contentTypeImageFields()); |
| | | //Looks like product requires some major heavy lifting. Best to skip for now |
| | | // $this->contentTypes['product'] = array_merge($base, $this->contentTypeProductFields()); |
| | | |
| | | |
| | | } |
| | | protected function getBaseFields():array |
| | | { |
| | | return [ |
| | | |
| | | ]; |
| | | } |
| | | protected function contentTypePostFields():array |
| | | { |
| | | return []; |
| | | } |
| | | protected function contentTypeImageFields():array |
| | | { |
| | | return []; |
| | | } |
| | | protected function initialize(): void |
| | | { |
| | | // TODO: Implement initialize() method. |
| | |
| | | { |
| | | return [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | 'Accept' => 'application/json', |
| | | ]; |
| | | } |
| | | |
| | | |
| | | protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array |
| | | protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array |
| | | { |
| | | if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') { |
| | | return $this->response(false, 'Rate limit exceeded'); |
| | | } |
| | | |
| | | $errors = $this->checkErrors($response); |
| | | if (!empty($errors)) { |
| | | return $errors; |
| | | } |
| | | |
| | | $endpoint = str_replace($this->baseUrl, '', $endpoint); |
| | | return match ($endpoint) { |
| | | '/me/accounts?access_token='.$this->getAccessToken() => $this->handleGetPagesResponse($response, $method), |
| | | default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method), |
| | | default => $response |
| | | }; |
| | | } |
| | | protected function checkErrors(WP_Error|array $response):array|false |
| | | { |
| | | if (!isset($response['error'])) { |
| | | return false; |
| | | } |
| | | $error = (string)$response['error']['code']; |
| | | switch ($error) { |
| | | //Invalid parameter |
| | | case '100': |
| | | //Too many calls by this account. Wait for a bit |
| | | case '80001': |
| | | //Permissions error |
| | | case '200': |
| | | //Action has been deemed abusive, or otherwise disallowed |
| | | case '368': |
| | | //Missing or invalid image file |
| | | case '324': |
| | | //Invalid OAuth 2.0 Access Token |
| | | case '190': |
| | | //Incorrect Signature |
| | | case '104': |
| | | //Desktop applications cannot call this function for other users |
| | | case '240': |
| | | //Requires additional permissions |
| | | case '283': |
| | | //Session is invalid; user has been checkpointed |
| | | case '459': |
| | | default: |
| | | $this->logError( |
| | | 'handleResponse', |
| | | 'Something went wrong: '.$response['Message']??'', |
| | | $response |
| | | ); |
| | | return $this->response(false, 'Something went wrong: '.$response['Message']??''); |
| | | } |
| | | } |
| | | /************************************************************ |
| | | * UTILITY METHODS |
| | | ************************************************************/ |
| | |
| | | $resp = $this->getRequest( |
| | | $this->baseUrl.'/me?access_token='.$cred['access_token'], |
| | | ); |
| | | $tmp = []; |
| | | if ($resp && !is_wp_error($resp)) { |
| | | $tmp['user_id'] = $resp['id']; |
| | | $tmp['user_name'] = $resp['name']; |
| | |
| | | { |
| | | return []; |
| | | } |
| | | |
| | | public function getOrCreateImage(int $imgID, string $caption = '', bool $publish = true, bool $returnResponse = false):string|array|false |
| | | { |
| | | $imgID = $this->getSupportedImage($imgID); |
| | | |
| | | $id = $this->getServiceItemID($imgID); |
| | | if (!empty($id)) { |
| | | return $id; |
| | | } |
| | | |
| | | $url = wp_get_attachment_url($imgID); |
| | | if (!file_exists($url)) { |
| | | return false; |
| | | } |
| | | |
| | | $args = [ |
| | | 'url' => $url |
| | | ]; |
| | | if (!empty($caption)) { |
| | | $caption = html_entity_decode(wp_strip_all_tags($caption)); |
| | | if (!empty($caption)) { |
| | | $args['caption'] = $caption; |
| | | } |
| | | } |
| | | |
| | | $alt_text = get_post_meta($imgID, '_wp_attachment_image_alt', true); |
| | | if (!empty($alt_text)) { |
| | | $args['alt_text_custom'] = esc_html($alt_text); |
| | | } |
| | | |
| | | if (!$publish) { |
| | | $args['published'] = false; |
| | | } |
| | | |
| | | $args['access_token'] = $this->getAccessToken(); |
| | | |
| | | $response = $this->postRequest( |
| | | sprintf( |
| | | '%s/%s/photos', |
| | | $this->baseUrl, |
| | | $this->getPageID(), |
| | | ), |
| | | $args |
| | | ); |
| | | |
| | | if (isset($response['id'])) { |
| | | $this->setServiceItemID($imgID, $response['id']); |
| | | } |
| | | |
| | | return $returnResponse ? $response : $response['id']; |
| | | |
| | | |
| | | } |
| | | |
| | | /********************************************************** |
| | | * POSTING UTILITIES |
| | | **********************************************************/ |
| | | public function post(string $message):array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return $this->response(false, 'Not set up.'); |
| | | } |
| | | $message = html_entity_decode(wp_strip_all_tags($message)); |
| | | if (empty($message)) { |
| | | return $this->response(false, 'Message cannot be empty.'); |
| | | } |
| | | $args = [ |
| | | 'message' => $message, |
| | | ]; |
| | | |
| | | $args['access_token'] = $this->getAccessToken(); |
| | | |
| | | return $this->postRequest( |
| | | sprintf( |
| | | '%s/%s/feed', |
| | | $this->baseUrl, |
| | | $this->getPageID(), |
| | | ), |
| | | $args |
| | | ); |
| | | } |
| | | /** |
| | | * From https://developers.facebook.com/docs/graph-api/reference/photo/#u_0_o_gP |
| | | * |
| | | * @param int $imgID |
| | | * @param string $caption |
| | | * @return array |
| | | */ |
| | | public function postSingleImage(int $imgID, string $caption):array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return $this->response(false, 'Not set up yet'); |
| | | } |
| | | |
| | | $response = $this->getOrCreateImage($imgID, $caption); |
| | | |
| | | return $this->response(true, 'Posted Image', $response); |
| | | } |
| | | |
| | | public function postGalleryImages(array $imgIDs, string $caption, int $postID = 0):array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return $this->response(false, 'Not set up yet'); |
| | | } |
| | | |
| | | $post = []; |
| | | $i = 0; |
| | | foreach ($imgIDs as $imgID) { |
| | | $id = $this->getOrCreateImage($imgID, '', false); |
| | | if ($id) { |
| | | $post['attached_media['.$i.']'] = json_encode(['media_fbid' => $id]); |
| | | $i++; |
| | | } |
| | | } |
| | | |
| | | if (empty($post)) { |
| | | return $this->response(false, 'No images posted'); |
| | | } |
| | | |
| | | $post['access_token'] = $this->getAccessToken(); |
| | | |
| | | $caption = html_entity_decode(wp_strip_all_tags($caption)); |
| | | if (!empty($caption)) { |
| | | $post['message'] = $caption; |
| | | } |
| | | |
| | | $response = $this->postRequest( |
| | | sprintf( |
| | | '%s/%s/feed', |
| | | $this->baseUrl, |
| | | $this->getPageID(), |
| | | ), |
| | | $post |
| | | ); |
| | | |
| | | if ($postID > 0 && isset($response['id'])) { |
| | | $this->setServiceItemID($postID, $response['id']); |
| | | } |
| | | |
| | | return $this->response(true, 'Successfully posted to Facebook', $response); |
| | | } |
| | | |
| | | public function getReviews():array |
| | | { |
| | | if (!$this->isSetUp() || !$this->getPageID()) { |
| | | return []; |
| | | } |
| | | $ratings = $this->getRequest( |
| | | sprintf( |
| | | '%s/%s/ratings', |
| | | $this->baseUrl, |
| | | $this->getPageID(), |
| | | ) |
| | | ); |
| | | if (!isset($ratings['data'])) { |
| | | return []; |
| | | } |
| | | return $ratings['data']; |
| | | } |
| | | |
| | | /** |
| | | * https://developers.facebook.com/docs/graph-api/reference/page/#parameters-3 |
| | | * @param array $data allowed keys are: about, description, phone, website, emails, location {json object with street, city, state, zip, country} |
| | | * @return array |
| | | * TODO: Cover photo? (cover) |
| | | * TODO: Food style (food_styles) |
| | | */ |
| | | public function updateBusiness(array $data):array |
| | | { |
| | | if (!$this->isSetUp() || !$this->getPageID()) { |
| | | return $this->response(false, 'Not set up yet'); |
| | | } |
| | | $data = array_filter($data, function($item) use ($data) { |
| | | if (!in_array($item, ['about', 'description', 'phone', 'website', 'emails', 'location', 'hours'])) { |
| | | $this->logError('updateBusiness', 'Attempted to set property that is not allowed: '.$item.' with value: '.print_r($data[$item], true)); |
| | | return false; |
| | | } |
| | | return true; |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | $out = []; |
| | | foreach ($data as $prop => $val) { |
| | | $out[$prop] = match($prop) { |
| | | 'about', 'description' => sanitize_text_field($val), |
| | | 'phone' => Sanitizer::sanitizePhone($val), |
| | | 'emails' => $this->sanitizeEmails($val), |
| | | 'website' => sanitize_url($val), |
| | | 'location' => $this->sanitizeLocation($val), |
| | | 'hours' => $this->format_hours($val), |
| | | default => null |
| | | }; |
| | | } |
| | | $out = array_filter($out); |
| | | |
| | | if (empty($out)) { |
| | | return $this->response(false, 'No data to update'); |
| | | } |
| | | |
| | | $out['access_token'] = $this->getAccessToken(); |
| | | |
| | | $response = $this->postRequest( |
| | | sprintf( |
| | | '%s/%s', |
| | | $this->baseUrl, |
| | | $this->getPageID() |
| | | ), |
| | | $out |
| | | ); |
| | | |
| | | $success = $response['success']??false; |
| | | return $this->response($success, $success ? 'Successfully updated information' : 'Failed to update information'); |
| | | } |
| | | protected function format_hours(array $days):array|null |
| | | { |
| | | //FB expects it to be: |
| | | // - first 3 characters of day of week (mon) |
| | | // - either 1 or 2, to allow for two set hours (ex: noon break) |
| | | // - open or close |
| | | //example: mon_1_open: 11:00, mon_1_close: 18:00 |
| | | //To accomplish this, we will assume the hours are formatted as with the SEO schema |
| | | $days = array_filter($days, function ($day) { |
| | | return in_array($day, ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | $out = []; |
| | | foreach ($days as $day => $conf) { |
| | | if (array_key_exists('isOpen', $conf) && $conf['isOpen'] === 1 |
| | | && array_key_exists('opens', $conf) && !empty($conf['opens']) |
| | | && array_key_exists('closes', $conf) && !empty($conf['closes']) |
| | | ) { |
| | | $short = substr($day, 0, 3); |
| | | |
| | | $out[$short.'_1_open'] = $conf['opens']; //Should be stored in 24hr time already |
| | | $out[$short.'_1_close'] = $conf['closes']; |
| | | } |
| | | } |
| | | |
| | | return empty($out) ? null : $out; |
| | | } |
| | | protected function sanitizeLocation(array $location):array|null |
| | | { |
| | | if (array_key_exists('province', $location)) { |
| | | $location['state'] = $location['province']; |
| | | unset($location['province']); |
| | | } |
| | | if (array_key_exists('postal_code', $location)) { |
| | | $location['zip'] = $location['postal_code']; |
| | | unset($location['postal_code']); |
| | | } |
| | | $location = array_filter($location, function ($item) use ($location) { |
| | | if (!in_array($item, ['street', 'city', 'state', 'zip', 'country'])) { |
| | | $this->logError('sanitizeLocation', 'Attempted to set property that is not allowed: '.$item.', with value: '.print_r($location[$item], true)); |
| | | return false; |
| | | } |
| | | if (!is_string($location[$item])) { |
| | | $this->logError('sanitizeLocation', 'Unexpected value for '.$item.': '.print_r($location[$item], true)); |
| | | return false; |
| | | } |
| | | return true; |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | return empty($location) ? null : $location; |
| | | } |
| | | protected function sanitizeEmails(array $emails):array|null |
| | | { |
| | | $emails = array_filter($emails, function($item) { |
| | | return (bool)filter_var($item, FILTER_VALIDATE_EMAIL); |
| | | }); |
| | | return empty($emails) ? null : $emails; |
| | | } |
| | | } |