Jake Vanderwerf
2026-09-14 7935d26cd16a4f8f69a59a44e27a7263b60e54bf
inc/integrations/services/Facebook.php
@@ -7,7 +7,7 @@
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;
@@ -17,6 +17,7 @@
use JVBase\integrations\SyncFrom;
use JVBase\integrations\SyncTo;
use JVBase\integrations\UserConnection;
use JVBase\meta\Sanitizer;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -40,22 +41,25 @@
      $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();
   }
@@ -134,6 +138,31 @@
      {
         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.
@@ -148,23 +177,64 @@
   {
      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
   ************************************************************/
@@ -188,6 +258,7 @@
         $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'];
@@ -275,4 +346,272 @@
   {
      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;
      }
}