service_name = 'facebook'; $this->title = 'Facebook'; $this->icon = 'facebook-logo'; $this->apiVersion = 'v26.0'; $this->canSync = [ 'create' => true, 'update' => false, 'delete' => false ]; $this->defineFields(); $this->defineInstructions(); $this->defineOAuth(); $this->defineContentTypes(); $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion; $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion; $this->supportsWebp = false; parent::__construct(); } protected function defineFields():void { $client_id = $this->addField( 'client_id', 'App ID', 'text', 'manage_options' ); $client_id->setRequired(); $client_id->setHint('Your Facebook App ID from developers.facebook.com'); $client_secret = $this->addField( 'client_secret', 'App Secret', 'text', 'manage_options' ); $client_secret->setRequired(); $client_secret->setSubType('password'); $pages = $this->addField('pages', 'Pages', 'select', [$this, 'testUserCanConnect']); $pages->setHidden(); $pages->setOptions($this->getPageOptions()); $page = $this->addField('page_id', 'Connect to Page', 'select', [$this, 'testUserCanConnect']); $page->setOptions($this->loadCredentials()['pages']??[]); $page->setHint('Select the page you want to connect this site to.'); } public function testUserCanConnect():bool { $user = get_current_user_id(); return $this->userCanConnect($user); } protected function getPageOptions():array { return $this->loadCredentials()['pages']??[]; } protected function defineInstructions():void { } protected function defineActions():void { $this->addAction( 'Select Page', [$this, 'handleSelectPage'], 'select_page', 'plugs-connected' ); $this->addAction( 'Fetch Pages', [$this, 'getPages'], 'fetch_pages', 'download-simple' ); } public function defineOAuth():void { $this->oauth = new OAuthURLs( 'https://www.facebook.com/'.$this->apiVersion.'/dialog/oauth', 'https://graph.facebook.com/'.$this->apiVersion.'/oauth/access_token', 'https://graph.facebook.com/'.$this->apiVersion.'/me/permissions' ); $this->oathScopes = [ 'pages_show_list', 'business_management', 'pages_manage_metadata', 'pages_manage_posts' ]; } public function getServiceDescription():string { 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. } protected function refreshAccessToken(OAuthCredentials $credentials): array { return $this->response(false, 'Not set up yet'); } protected function getRequestHeaders(): array { return [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; } 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 => $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 ************************************************************/ protected function getPageID():string|false { return $this->loadCredentials()['page_id']??false; } protected function getClientID():string|false { return $this->loadCredentials()['client_id']??false; } protected function getClientSecret():string|false { return $this->loadCredentials()['client_secret']??false; } protected function getFBUserID():string|false { $cred = $this->loadCredentials(); if ($this->isSetUp() && !array_key_exists('user_id', $cred)) { $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']; $this->saveCredentials($tmp, false); } $cred = array_merge($cred, $tmp); } return $cred['user_id']??false; } public function getPages():array|false { if (!$this->getAccessToken()) { return false; } try { return $this->getRequest( $this->apiBase.'/me/accounts?access_token='.$this->getAccessToken(), ); } catch (Exception $e) { $this->logError( 'getPages', 'Failed to load pages', [ 'error' => $e->getMessage(), ] ); } return []; } protected function handleGetPagesResponse(WP_Error|array $response, string $method):array { $options = []; foreach ($response as $page) { if (array_key_exists('id', $page) && array_key_exists('name', $page)) { $options[$page['id']] = $page['name']; } } if (!empty($options)) { $this->saveCredentials(['pages' => $options]); } return $this->response(true, 'Found pages', $options); } /************************************************************ * ACTIONS ************************************************************/ public function handleSelectPage(array $data):array { if (empty($data['page_id']??'')) { return $this->response(false, 'No page selected.'); } $cred = [ 'page_id' => sanitize_text_field($data['page_id']), ]; return $this->saveCredentials($cred, false); } public function handleFetchPages():array { try { $pages = $this->getPages(); if (!$pages) { return $this->response(false, 'No pages found'); } return $this->response( true, sprintf('Found %d page(s)', count($pages)), $pages ); } catch (Exception $e) { return $this->response(false, 'Failed to fetch pages: '.$e->getMessage()); } } protected function setContentTypes(): void { // TODO: Implement setContentTypes() method. } protected function formatForService(int $itemID, string $type = 'post'): array { 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; } }