| | |
| | | */ |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\integrations\OAuth; |
| | | use JVBase\integrations\OAuthCredentials; |
| | | use JVBase\integrations\OAuthURLs; |
| | | use JVBase\integrations\Requests; |
| | | use JVBase\integrations\SyncDefaults; |
| | | use JVBase\integrations\SyncFrom; |
| | | use JVBase\integrations\SyncTo; |
| | | use JVBase\integrations\UserConnection; |
| | | use JVBase\meta\Meta; |
| | | use WP_Post; |
| | | use JVBase\meta\Sanitizer; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | |
| | | class Facebook extends Integrations |
| | | { |
| | | // Facebook-specific properties |
| | | protected array $allowedContent = [ |
| | | 'post', |
| | | 'photo', |
| | | 'video', |
| | | 'event', |
| | | 'offer', |
| | | 'note', |
| | | 'milestone' |
| | | ]; |
| | | private string $page_id = ''; |
| | | private string $page_access_token = ''; |
| | | private array $permissions = []; |
| | | private array $pages = []; |
| | | use Admin, |
| | | Instance, |
| | | OAuth, |
| | | UserConnection, |
| | | Requests, |
| | | SyncFrom, |
| | | SyncTo, |
| | | SyncDefaults; |
| | | |
| | | // Post type mapping for Facebook |
| | | private const FB_POST_TYPES = [ |
| | | 'post' => 'feed', |
| | | 'photo' => 'photos', |
| | | 'video' => 'videos', |
| | | 'event' => 'events', |
| | | 'offer' => 'offers', |
| | | 'note' => 'notes', |
| | | 'milestone' => 'milestones' |
| | | ]; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | protected function __construct() |
| | | { |
| | | $this->service_name = 'facebook'; |
| | | $this->title = 'Facebook'; |
| | | $this->icon = 'facebook-logo'; |
| | | |
| | | // API Configuration |
| | | $this->apiBase = [ |
| | | 'graph' => 'https://graph.facebook.com/v18.0', |
| | | 'auth' => 'https://www.facebook.com/v18.0' |
| | | ]; |
| | | $this->apiVersion = 'v26.0'; |
| | | |
| | | $this->apiVersion = 'v18.0'; |
| | | |
| | | $this->isOAuthService = true; |
| | | // OAuth Configuration |
| | | $this->oauth = [ |
| | | 'authorize' => 'https://www.facebook.com/v18.0/dialog/oauth', |
| | | 'token' => 'https://graph.facebook.com/v18.0/oauth/access_token', |
| | | 'revoke' => 'https://graph.facebook.com/v18.0/me/permissions', |
| | | 'scopes' => [ |
| | | 'pages_show_list', |
| | | 'pages_read_engagement', |
| | | 'pages_manage_posts', |
| | | 'pages_manage_metadata', |
| | | 'pages_manage_engagement', |
| | | 'pages_read_user_content', |
| | | 'business_management', |
| | | 'instagram_basic', |
| | | 'instagram_content_publish' |
| | | ] |
| | | ]; |
| | | |
| | | // Sync capabilities |
| | | $this->canSync = [ |
| | | 'initial' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | 'create' => true, |
| | | 'update' => false, |
| | | 'delete' => false |
| | | ]; |
| | | |
| | | // Rate limits for Facebook Graph API |
| | | $this->rate_limits = [ |
| | | 'per_second' => 10, |
| | | 'per_minute' => 600, |
| | | 'per_hour' => 200000 |
| | | ]; |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | | $this->defineOAuth(); |
| | | $this->defineActions(); |
| | | $this->defineContentTypes(); |
| | | |
| | | $this->fields = [ |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'placeholder' => 'Enter Facebook App ID', |
| | | 'hint' => 'Your Facebook App ID from developers.facebook.com', |
| | | 'label' => 'App ID', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype'=> 'password', |
| | | 'label' => 'App Secret', |
| | | 'required' => true, |
| | | 'hint' => 'Your Facebook App Secret (keep this secure!)' |
| | | ], |
| | | ]; |
| | | $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion; |
| | | $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | |
| | | ]; |
| | | |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | $this->hasWebhooks = true; |
| | | $this->supportsWebp = false; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge($this->actions, [ |
| | | 'sync_pages' => 'getUserPages' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize Facebook-specific properties |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | $this->page_id = $this->credentials['page_id'] ?? ''; |
| | | $this->page_access_token = $this->credentials['page_access_token'] ?? ''; |
| | | $this->permissions = $this->credentials['permissions'] ?? []; |
| | | 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'); |
| | | |
| | | // Build dynamic endpoints based on page |
| | | if ($this->page_id) { |
| | | $this->apiEndpoints = [ |
| | | "/{$this->page_id}/feed", |
| | | "/{$this->page_id}/photos", |
| | | "/{$this->page_id}/videos", |
| | | "/{$this->page_id}/events", |
| | | "/{$this->page_id}/insights", |
| | | "/{$this->page_id}/conversations", |
| | | "/{$this->page_id}", |
| | | "/me/accounts" |
| | | ]; |
| | | $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.'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect your Facebook Page to share posts, events, and engage with your audience."; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for Facebook API |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | $headers = [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | |
| | | // Use page access token if available, otherwise user token |
| | | if (!empty($this->page_access_token)) { |
| | | // Token is sent in URL params for Facebook, not headers |
| | | } |
| | | |
| | | return $headers; |
| | | } |
| | | |
| | | /** |
| | | * Add Facebook-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | $params['display'] = 'popup'; |
| | | $params['auth_type'] = 'rerequest'; |
| | | $params['return_scopes'] = 'true'; |
| | | return $params; |
| | | } |
| | | |
| | | /** |
| | | * Add Facebook-specific credential data after OAuth |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Get user's pages after OAuth |
| | | try { |
| | | $pages = $this->getUserPages($tokens['access_token']); |
| | | if (!empty($pages)) { |
| | | $credentials['pages'] = $pages; |
| | | // Auto-select first page if not already selected |
| | | if (empty($credentials['page_id']) && !empty($pages[0])) { |
| | | $credentials['page_id'] = $pages[0]['id']; |
| | | $credentials['page_access_token'] = $pages[0]['access_token']; |
| | | $credentials['page_name'] = $pages[0]['name']; |
| | | } |
| | | 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 |
| | | { |
| | | |
| | | // Store permissions granted |
| | | $permissions = $this->getGrantedPermissions($tokens['access_token']); |
| | | $credentials['permissions'] = $permissions; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Failed to fetch Facebook pages', ['error' => $e->getMessage()]); |
| | | } |
| | | |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Test Facebook connection |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | if (empty($this->credentials['access_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | try { |
| | | // Test by fetching user info |
| | | $response = wp_remote_get( |
| | | $this->apiBase['graph'] . '/me?access_token=' . $this->credentials['access_token'] |
| | | protected function defineActions():void |
| | | { |
| | | $this->addAction( |
| | | 'Select Page', |
| | | [$this, 'handleSelectPage'], |
| | | 'select_page', |
| | | 'plugs-connected' |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $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; |
| | | } |
| | | |
| | | $code = wp_remote_retrieve_response_code($response); |
| | | return $code === 200; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Render Additional OAuth settings |
| | | */ |
| | | protected function renderOAuthConnectedOptions(): void |
| | | { |
| | | // Page selection dropdown |
| | | if (!empty($this->credentials['pages'])) { |
| | | ?> |
| | | <div class="form-field"> |
| | | <label for="fb_page">Active Facebook Page</label> |
| | | <select name="credentials[page_id]" id="fb_page"> |
| | | <option value="">Select a page...</option> |
| | | <?php foreach ($this->credentials['pages'] as $page): ?> |
| | | <option value="<?php echo esc_attr($page['id']); ?>" |
| | | <?php selected($this->credentials['page_id'] ?? '', $page['id']); ?>> |
| | | <?php echo esc_html($page['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate webhook from Facebook |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Handle webhook verification challenge |
| | | if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') { |
| | | $verify_token = $this->credentials['webhook_verify_token'] ?? ''; |
| | | if ($payload['hub_verify_token'] === $verify_token) { |
| | | // Echo the challenge for verification |
| | | echo $payload['hub_challenge']; |
| | | return true; |
| | | $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']??''); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | // Validate webhook signature for actual events |
| | | $headers = $payload['_headers'] ?? []; |
| | | $signature = $headers['x-hub-signature-256'] ?? ''; |
| | | |
| | | if (empty($signature)) { |
| | | $this->logError('Missing webhook signature'); |
| | | return false; |
| | | } |
| | | |
| | | // Calculate expected signature |
| | | $app_secret = $this->credentials['client_secret'] ?? ''; |
| | | $expected = 'sha256=' . hash_hmac('sha256', json_encode($payload), $app_secret); |
| | | |
| | | return hash_equals($expected, $signature); |
| | | /************************************************************ |
| | | * 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; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook from Facebook |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | 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 { |
| | | $object = $payload['object'] ?? ''; |
| | | $entry = $payload['entry'] ?? []; |
| | | $pages = $this->getPages(); |
| | | if (!$pages) { |
| | | return $this->response(false, 'No pages found'); |
| | | } |
| | | |
| | | foreach ($entry as $item) { |
| | | $changes = $item['changes'] ?? []; |
| | | 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()); |
| | | } |
| | | } |
| | | |
| | | foreach ($changes as $change) { |
| | | $field = $change['field'] ?? ''; |
| | | $value = $change['value'] ?? []; |
| | | protected function setContentTypes(): void |
| | | { |
| | | // TODO: Implement setContentTypes() method. |
| | | } |
| | | |
| | | switch ($field) { |
| | | case 'feed': |
| | | $this->handleFeedUpdate($value); |
| | | break; |
| | | protected function formatForService(int $itemID, string $type = 'post'): array |
| | | { |
| | | return []; |
| | | } |
| | | |
| | | case 'conversations': |
| | | $this->handleMessageUpdate($value); |
| | | break; |
| | | public function getOrCreateImage(int $imgID, string $caption = '', bool $publish = true, bool $returnResponse = false):string|array|false |
| | | { |
| | | $imgID = $this->getSupportedImage($imgID); |
| | | |
| | | case 'photos': |
| | | $this->handlePhotoUpdate($value); |
| | | break; |
| | | $id = $this->getServiceItemID($imgID); |
| | | if (!empty($id)) { |
| | | return $id; |
| | | } |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook field', ['field' => $field]); |
| | | } |
| | | $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 true; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Webhook processing failed', [ |
| | | 'error' => $e->getMessage(), |
| | | 'payload' => $payload |
| | | ]); |
| | | return false; |
| | | return empty($out) ? null : $out; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle post save for Facebook sync |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | { |
| | | try { |
| | | $fb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | switch ($operation->type) { |
| | | case 'facebook_create_post': |
| | | return $fb->createFacebookPost($data); |
| | | |
| | | case 'facebook_update_post': |
| | | return $fb->updateFacebookPost($data); |
| | | |
| | | case 'facebook_delete_post': |
| | | return $fb->deleteFacebookPost($data); |
| | | |
| | | case 'facebook_create_event': |
| | | return $fb->createFacebookEvent($data); |
| | | |
| | | default: |
| | | return $result; |
| | | protected function sanitizeLocation(array $location):array|null |
| | | { |
| | | if (array_key_exists('province', $location)) { |
| | | $location['state'] = $location['province']; |
| | | unset($location['province']); |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->logError('Operation failed', [ |
| | | 'type' => $operation->type, |
| | | 'error' => $e->getMessage() |
| | | ]); |
| | | return new WP_Error('operation_failed', $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a Facebook post |
| | | */ |
| | | private function createFacebookPost(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | if (!$post) { |
| | | return ['success' => false, 'error' => 'Post not found']; |
| | | } |
| | | |
| | | // Build Facebook post data |
| | | $fb_data = [ |
| | | 'message' => $this->formatPostContent($post), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | // Add link if present |
| | | $link = get_post_meta($post->ID, 'link_url', true); |
| | | if ($link) { |
| | | $fb_data['link'] = $link; |
| | | } |
| | | |
| | | // Handle featured image |
| | | if (has_post_thumbnail($post->ID)) { |
| | | $image_url = get_the_post_thumbnail_url($post->ID, 'large'); |
| | | $fb_data['picture'] = $image_url; |
| | | } |
| | | |
| | | // Determine endpoint |
| | | $endpoint = $data['endpoint'] ?? 'feed'; |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/' . $endpoint; |
| | | |
| | | // Make the API request |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $fb_data, |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | // Store Facebook post ID |
| | | update_post_meta($post->ID, BASE . '_facebook_post_id', $body['id']); |
| | | update_post_meta($post->ID, BASE . '_facebook_sync_time', time()); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'facebook_id' => $body['id'], |
| | | 'message' => 'Posted to Facebook successfully' |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | |
| | | /** |
| | | * Update a Facebook post |
| | | */ |
| | | private function updateFacebookPost(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | $fb_post_id = get_post_meta($post->ID, BASE . '_facebook_post_id', true); |
| | | |
| | | if (!$fb_post_id) { |
| | | // No existing post, create new one |
| | | return $this->createFacebookPost($data); |
| | | } |
| | | |
| | | // Facebook doesn't allow editing most post types after creation |
| | | // We can only update certain fields like message |
| | | $fb_data = [ |
| | | 'message' => $this->formatPostContent($post), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $fb_post_id; |
| | | |
| | | $response = wp_remote_request($url, [ |
| | | 'method' => 'POST', |
| | | 'body' => $fb_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | update_post_meta($post->ID, BASE . '_facebook_sync_time', time()); |
| | | |
| | | return ['success' => true, 'message' => 'Updated on Facebook']; |
| | | } |
| | | |
| | | /** |
| | | * Delete a Facebook post |
| | | */ |
| | | private function deleteFacebookPost(array $data): array |
| | | { |
| | | $fb_post_id = get_post_meta($data['post_id'], BASE . '_facebook_post_id', true); |
| | | |
| | | if (!$fb_post_id) { |
| | | return ['success' => true, 'message' => 'No Facebook post to delete']; |
| | | } |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $fb_post_id . '?access_token=' . $this->page_access_token; |
| | | |
| | | $response = wp_remote_request($url, ['method' => 'DELETE']); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | delete_post_meta($data['post_id'], BASE . '_facebook_post_id'); |
| | | delete_post_meta($data['post_id'], BASE . '_facebook_sync_time'); |
| | | } |
| | | |
| | | return ['success' => true, 'message' => 'Deleted from Facebook']; |
| | | } |
| | | |
| | | /** |
| | | * Create a Facebook event |
| | | */ |
| | | private function createFacebookEvent(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | $meta = Meta::forPost($post->ID); |
| | | |
| | | $event_data = [ |
| | | 'name' => $post->post_title, |
| | | 'description' => $this->formatPostContent($post), |
| | | 'start_time' => $meta->get('event_start_date'), |
| | | 'end_time' => $meta->get('event_end_date'), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | // Add location if available |
| | | $location = $meta->get('event_location'); |
| | | if ($location) { |
| | | $event_data['location'] = $location; |
| | | } |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/events'; |
| | | |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $event_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | update_post_meta($post->ID, BASE . '_facebook_event_id', $body['id']); |
| | | return ['success' => true, 'event_id' => $body['id']]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Get user's Facebook pages |
| | | */ |
| | | private function getUserPages(?string $access_token = null): array |
| | | { |
| | | $token = $access_token ?: $this->credentials['access_token']; |
| | | |
| | | $url = $this->apiBase['graph'] . '/me/accounts?access_token=' . $token; |
| | | |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception($response->get_error_message()); |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['error'])) { |
| | | throw new Exception($body['error']['message']); |
| | | } |
| | | |
| | | return $body['data'] ?? []; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Get granted permissions |
| | | */ |
| | | private function getGrantedPermissions(string $access_token): array |
| | | { |
| | | $url = $this->apiBase['graph'] . '/me/permissions?access_token=' . $access_token; |
| | | |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return []; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | $permissions = []; |
| | | |
| | | foreach (($body['data'] ?? []) as $permission) { |
| | | if ($permission['status'] === 'granted') { |
| | | $permissions[] = $permission['permission']; |
| | | 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; |
| | | } |
| | | |
| | | return $permissions; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Format post content for Facebook |
| | | */ |
| | | private function formatPostContent(WP_Post $post): string |
| | | { |
| | | $content = $post->post_content; |
| | | |
| | | // Strip shortcodes |
| | | $content = strip_shortcodes($content); |
| | | |
| | | // Convert to plain text |
| | | $content = wp_strip_all_tags($content); |
| | | |
| | | // Add link to full post |
| | | $permalink = get_permalink($post->ID); |
| | | $content .= "\n\nRead more: " . $permalink; |
| | | |
| | | // Trim to Facebook's character limit (63,206 characters) |
| | | if (strlen($content) > 63000) { |
| | | $content = substr($content, 0, 63000) . '...'; |
| | | 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; |
| | | } |
| | | |
| | | return $content; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle feed updates from webhook |
| | | */ |
| | | private function handleFeedUpdate(array $data): void |
| | | { |
| | | // Process incoming feed updates |
| | | // Could sync comments, reactions, etc. back to WordPress |
| | | $this->logDebug('Feed update received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle message updates from webhook |
| | | */ |
| | | private function handleMessageUpdate(array $data): void |
| | | { |
| | | // Process incoming messages |
| | | // Could create notifications or queue responses |
| | | $this->logDebug('Message received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle photo updates from webhook |
| | | */ |
| | | private function handlePhotoUpdate(array $data): void |
| | | { |
| | | // Process photo updates |
| | | $this->logDebug('Photo update received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Send test post to Facebook |
| | | */ |
| | | private function sendTestPost(): array |
| | | { |
| | | $test_data = [ |
| | | 'message' => 'Test post from ' . get_bloginfo('name') . ' at ' . current_time('mysql'), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/feed'; |
| | | |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $test_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | return ['success' => true, 'post_id' => $body['id']]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | } |