Jake Vanderwerf
2026-08-07 a8ff85042ffbfe94b678fd818c02deb613bfd116
inc/integrations/services/Instagram.php
@@ -2,11 +2,19 @@
/**
 * Instagram Integration
 * File: /inc/integrations/Instagram.php
 *
 * Limits:
 *    - 50 uploads (including carousels) per user per 24-hour period
 */
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
if (!defined('ABSPATH')) {
   exit;
@@ -14,692 +22,56 @@
class Instagram extends Integrations
{
   protected array $allowedContent = [
      'post'
   ];
   private string $ig_user_id = '';
   private string $page_id = '';
   private string $page_access_token = '';
   private array $business_account = [];
   private bool $auto_post = false;
   private array $post_settings = [];
   // Instagram API endpoints
   private const API_VERSION = 'v18.0';
   private const GRAPH_API_BASE = 'https://graph.facebook.com/';
   private const INSTAGRAM_BASE = 'https://graph.instagram.com/';
   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)
   use Admin, Instance, OAuth, Requests;
   protected function __construct()
   {
      $this->service_name = 'instagram';
      $this->title = 'Instagram';
      $this->icon = 'instagram-logo';
      $this->apiBase = [
         'graph' => self::GRAPH_API_BASE . self::API_VERSION,
         'instagram' => self::INSTAGRAM_BASE . self::API_VERSION
      ];
      $this->apiEndpoints = [
         'me/accounts',
         'media',
         'media_publish',
         'content_publishing_limit',
         'insights'
      ];
      $this->isOAuthService = true;
      // OAuth configuration for Facebook/Instagram
      $this->oauth = [
         'authorize' => 'https://www.facebook.com/' . self::API_VERSION . '/dialog/oauth',
         'token' => self::GRAPH_API_BASE . self::API_VERSION . '/oauth/access_token',
         'redirect_uri' => rest_url('jvb/v1/oauth/instagram'),
         'scopes' => [
            'instagram_basic',
            'instagram_content_publish',
            'instagram_manage_insights',
            'pages_show_list',
            'pages_read_engagement',
            'business_management'
         ]
      ];
      $this->apiVersion = 'v25.0';
      $this->supportsWebp = false;
      // Define sync capabilities
      $this->canSync = [
         'create' => true,
         'update' => false, // Instagram doesn't allow editing posts
         'delete' => true
      ];
//    $this->defineFields();
      $this->defineOAuth();
//    $this->defineActions();
      $this->fields = [
      ];
      $this->advanced = [
      ];
      $this->instructions = [
         'Setup Facebook in order to use Instagram.'
      ];
      $this->defaults = [
      ];
      $this->hasWebhooks = true;
      parent::__construct($userID);
      $this->actions = array_merge(
         $this->actions,
         [
            'switch_account'  => 'switchInstagramAccount'
         ]
      );
      $this->baseUrl = 'https://graph.instagram.com/'.$this->apiVersion;
      parent::__construct();
   }
   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 = [
         'instagram_basic',
         'instagram_content_publish',
      ];
   }
   protected function initialize(): void
   {
      $this->page_access_token = $this->credentials['page_access_token'] ?? '';
      $this->ig_user_id = $this->credentials['ig_user_id'] ?? '';
      $this->page_id = $this->credentials['page_id'] ?? '';
      $this->business_account = $this->credentials['business_account'] ?? [];
      $this->auto_post = $this->credentials['auto_post'] ?? false;
      $this->post_settings = $this->credentials['post_settings'] ?? [
         'include_caption' => true,
         'include_hashtags' => true,
         'include_location' => false,
         'default_hashtags' => ''
      ];
      // Set rate limits
      $this->rate_limits = [
         'min_interval' => 30,
         'daily_limit' => 25, // Instagram content publishing limit
         'hourly_limit' => 5
      ];
      // TODO: Implement initialize() method.
   }
   /**
    * Get request headers for Instagram API
    */
   protected function refreshAccessToken(OAuthCredentials $credentials): array
   {
      // TODO: Implement refreshAccessToken() method.
      return [];
   }
   protected function getRequestHeaders(): array
   {
      return [
         'Content-Type' => 'application/json',
         'Accept' => 'application/json'
      ];
      // TODO: Implement getRequestHeaders() method.
      return [];
   }
   /**
    * Build Instagram API URL
    */
   protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
   protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
   {
      $base = ($baseKey === 'instagram') ? $this->apiBase['instagram'] : $this->apiBase['graph'];
      // Handle Instagram-specific endpoints
      if (in_array($endpoint, ['media', 'media_publish'])) {
         return "{$base}/{$this->ig_user_id}/{$endpoint}";
      }
      return "{$base}/{$endpoint}";
   }
   /**
    * Add OAuth parameters specific to Facebook/Instagram
    */
   protected function addOAuthParams(array $params): array
   {
      $params['display'] = 'popup';
      $params['auth_type'] = 'rerequest';
      return $params;
   }
   public function isSetUp():bool {
      $fb = JVB()->connect('facebook');
      return $fb->isSetUp();
   }
   protected function getOAuthCredentials(): array
   {
      // Get Facebook integration
      $facebook = JVB()->connect('facebook');
      if (!$facebook || !$facebook->isSetUp()) {
         return [];
      }
      $fbCredentials = $facebook->getCredentials();
      return [
         'client_id' => $fbCredentials['app_id'] ?? $fbCredentials['client_id'] ?? '',
         'client_secret' => $fbCredentials['app_secret'] ?? $fbCredentials['client_secret'] ?? '',
         'access_token' => $fbCredentials['access_token'] ?? '',
      ];
   }
   public function getCredentials():array {
      $credentials = parent::getCredentials();
      if (empty($credentials)) {
         $facebook = JVB()->connect('facebook');
         if (!$facebook || !$facebook->isSetUp()) {
            return [];
         }
         $fbCredentials = $facebook->getCredentials();
         return [
            'client_id' => $fbCredentials['client_id'],
            'client_secret' => $fbCredentials['client_secret'],
         ];
      }
      return $credentials;
   }
   private function switchInstagramAccount(array $data): WP_Error|array
   {
      $page_id = $data['page_id'] ?? '';
      if (empty($page_id)) {
         return [
            'success'   => false,
            'message'   => 'Page ID is required'
         ];
      }
      // Find the selected page in available pages
      $selected_page = null;
      foreach ($this->credentials['available_pages'] ?? [] as $page) {
         if ($page['id'] === $page_id) {
            $selected_page = $page;
            break;
         }
      }
      if (!$selected_page || empty($selected_page['instagram_business_account'])) {
         return [
            'success'   => false,
            'message'   => 'Invalid page or no Instagram account'
         ];
      }
      // Update credentials with new page
      $this->credentials['page_id'] = $selected_page['id'];
      $this->credentials['page_access_token'] = $selected_page['access_token'];
      $this->credentials['ig_user_id'] = $selected_page['instagram_business_account']['id'];
      $this->credentials['business_account'] = $this->getBusinessProfile(
         $selected_page['instagram_business_account']['id'],
         $selected_page['access_token']
      );
      $this->saveCredentials($this->credentials);
      // Reinitialize with new credentials
      $this->initialize();
      return [
         'success' => true,
         'message' => 'Switched to @' . ($this->credentials['business_account']['username'] ?? 'Instagram account'),
         'account' => $this->credentials['business_account']
      ];
   }
   /**
    * Handle post save - queue Instagram post creation
    */
   public 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;
      }
      if (!has_post_thumbnail($postID)) {
         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
   {
      $base = strtolower($this->service_name) . '_';
      $insta = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this;
      switch ($operation->type) {
         case $base . 'create_post':
            return $insta->processCreatePost($data);
         case $base . 'create_story':
            return $insta->processCreateStory($data);
         case $base . 'delete_post':
            return $insta->processDeletePost($data);
         default:
            return $result;
      }
   }
   private function processDeletePost(array $data): array
   {
      return [
         'success' => true,
         'message' => 'Instagram sync data removed (post remains on Instagram)',
         'note' => 'Instagram API does not support deleting posts programmatically'
      ];
   }
   /**
    * Process post creation for Instagram
    */
   private function processCreatePost(array $data): array
   {
      $post_id = $data['post_id'] ?? 0;
      $post = get_post($post_id);
      if (!$post) {
         return ['success' => false, 'message' => 'Post not found'];
      }
      try {
         // Get image URL
         $image_url = get_the_post_thumbnail_url($post_id, 'full');
         if (!$image_url) {
            throw new Exception('No featured image found');
         }
         // Build caption
         $caption = $this->buildCaption($post);
         // Create media container
         $container = $this->createMediaContainer($image_url, $caption);
         if (!$container || empty($container['id'])) {
            throw new Exception('Failed to create media container');
         }
         // Wait for processing (Instagram needs time to process the image)
         sleep(10);
         // Publish the media
         $published = $this->publishMedia($container['id']);
         if ($published && !empty($published['id'])) {
            // Store Instagram media ID
            update_post_meta($post_id, BASE . '_instagram_media_id', $published['id']);
            update_post_meta($post_id, BASE . '_instagram_sync_status', 'synced');
            update_post_meta($post_id, BASE . '_instagram_sync_date', current_time('mysql'));
            // Get permalink if available
            if (!empty($published['permalink'])) {
               update_post_meta($post_id, BASE . '_instagram_permalink', $published['permalink']);
            }
            return [
               'success' => true,
               'message' => 'Posted to Instagram successfully',
               'instagram_id' => $published['id']
            ];
         }
         throw new Exception('Failed to publish media');
      } catch (Exception $e) {
         $this->logError('Instagram post creation failed', [
            'post_id' => $post_id,
            'error' => $e->getMessage()
         ]);
         update_post_meta($post_id, BASE . '_instagram_sync_status', 'failed');
         update_post_meta($post_id, BASE . '_instagram_sync_error', $e->getMessage());
         return [
            'success' => false,
            'message' => $e->getMessage()
         ];
      }
   }
   /**
    * Build caption for Instagram post
    */
   private function buildCaption(\WP_Post $post): string
   {
      $caption_parts = [];
      // Add title and description if enabled
      if ($this->post_settings['include_caption'] ?? true) {
         $caption_parts[] = $post->post_title;
         if (!empty($post->post_excerpt)) {
            $caption_parts[] = "\n\n" . $post->post_excerpt;
         }
      }
      // Add hashtags
      if ($this->post_settings['include_hashtags'] ?? true) {
         $hashtags = [];
         // Get tags from post
         $tags = wp_get_post_tags($post->ID, ['fields' => 'names']);
         foreach ($tags as $tag) {
            $hashtags[] = '#' . str_replace(' ', '', $tag);
         }
         // Add default hashtags
         if (!empty($this->post_settings['default_hashtags'])) {
            $default = preg_split('/[\s,]+/', $this->post_settings['default_hashtags']);
            foreach ($default as $tag) {
               $tag = trim($tag);
               if (strpos($tag, '#') !== 0) {
                  $tag = '#' . $tag;
               }
               $hashtags[] = $tag;
            }
         }
         if (!empty($hashtags)) {
            $caption_parts[] = "\n\n" . implode(' ', array_unique($hashtags));
         }
      }
      // Add website link
      $caption_parts[] = "\n\nšŸ”— " . get_permalink($post);
      return implode('', $caption_parts);
   }
   /**
    * Webhook validation
    */
   protected function validateWebhook(array $payload): bool
   {
      // Facebook webhook verification
      if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') {
         $verify_token = $this->credentials['webhook_verify_token'] ?? 'jvb_instagram_webhook';
         if ($payload['hub_verify_token'] === $verify_token) {
            // Echo the challenge for verification
            echo $payload['hub_challenge'];
            exit;
         }
         return false;
      }
      // Validate webhook signature for actual events
      $signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
      if (!$signature) {
         return false;
      }
      $app_secret = $this->credentials['app_secret'] ?? '';
      if (!$app_secret) {
         return true; // Allow if no secret configured
      }
      $expected = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $app_secret);
      return hash_equals($expected, $signature);
   }
   /**
    * Process webhook
    */
   protected function processWebhook(array $payload): bool
   {
      $entry = $payload['entry'][0] ?? [];
      $changes = $entry['changes'] ?? [];
      foreach ($changes as $change) {
         $field = $change['field'] ?? '';
         $value = $change['value'] ?? [];
         switch ($field) {
            case 'comments':
               $this->handleCommentWebhook($value);
               break;
            case 'mentions':
               $this->handleMentionWebhook($value);
               break;
            default:
               $this->logDebug('Unhandled webhook field', ['field' => $field]);
         }
      }
      return true;
   }
   /**
    * API Helper Methods
    */
   private function getFacebookPages(string $access_token): array
   {
      $response = wp_remote_get(
         $this->apiBase['graph'] . '/me/accounts?' . http_build_query([
            'access_token' => $access_token,
            'fields' => 'id,name,access_token,instagram_business_account'
         ])
      );
      if (is_wp_error($response)) {
         $this->logError('Failed to get Facebook pages', ['error' => $response->get_error_message()]);
         return [];
      }
      $data = json_decode(wp_remote_retrieve_body($response), true);
      return $data['data'] ?? [];
   }
   private function getBusinessProfile(string $ig_user_id, string $access_token): array
   {
      $response = wp_remote_get(
         $this->apiBase['graph'] . '/' . $ig_user_id . '?' . http_build_query([
            'access_token' => $access_token,
            'fields' => 'id,username,name,profile_picture_url,followers_count,media_count,biography,website'
         ])
      );
      if (is_wp_error($response)) {
         return [];
      }
      return json_decode(wp_remote_retrieve_body($response), true) ?: [];
   }
   private function createMediaContainer(string $image_url, string $caption): ?array
   {
      $response = wp_remote_post(
         $this->getApiUrl('media', 'instagram'),
         [
            'body' => [
               'image_url' => $image_url,
               'caption' => $caption,
               'access_token' => $this->page_access_token
            ]
         ]
      );
      if (is_wp_error($response)) {
         $this->logError('Failed to create media container', ['error' => $response->get_error_message()]);
         return null;
      }
      return json_decode(wp_remote_retrieve_body($response), true);
   }
   private function publishMedia(string $creation_id): ?array
   {
      $response = wp_remote_post(
         $this->getApiUrl('media_publish', 'instagram'),
         [
            'body' => [
               'creation_id' => $creation_id,
               'access_token' => $this->page_access_token
            ]
         ]
      );
      if (is_wp_error($response)) {
         $this->logError('Failed to publish media', ['error' => $response->get_error_message()]);
         return null;
      }
      return json_decode(wp_remote_retrieve_body($response), true);
   }
   private function getPublishingLimit(): array
   {
      $response = wp_remote_get(
         $this->getApiUrl('content_publishing_limit', 'instagram') . '?' . http_build_query([
            'access_token' => $this->page_access_token
         ])
      );
      if (is_wp_error($response)) {
         return new WP_Error('api_error', $response->get_error_message());
      }
      $data = json_decode(wp_remote_retrieve_body($response), true);
      return [
         'success' => true,
         'data' => [
            'quota_usage' => $data['data'][0]['quota_usage'] ?? 0,
            'quota_total' => $data['data'][0]['config']['quota_total'] ?? 25,
            'quota_duration' => $data['data'][0]['config']['quota_duration'] ?? 86400
         ]
      ];
   }
   protected function performConnectionTest(): bool
   {
      if (empty($this->ig_user_id) || empty($this->page_access_token)) {
         return false;
      }
      // Test by getting account info
      $profile = $this->getBusinessProfile($this->ig_user_id, $this->page_access_token);
      if (!empty($profile['id'])) {
         return true;
      }
      return false;
   }
   private function createStory(array $data): WP_Error|array
   {
      $image_url = $data['image_url'] ?? '';
      if (empty($image_url)) {
         return new WP_Error('missing_image', 'Image URL required for story');
      }
      // Queue story creation
      $this->queueOperation('create_story', [
         'image_url' => $image_url,
         'sticker_url' => $data['sticker_url'] ?? ''
      ], [
         'priority' => 'high'
      ]);
      return [
         'success' => true,
         'message' => 'Story creation queued'
      ];
   }
   private function processCreateStory(array $data): array
   {
      $params = [
         'image_url' => $data['image_url'],
         'media_type' => 'STORIES',
         'access_token' => $this->page_access_token
      ];
      if (!empty($data['sticker_url'])) {
         $params['sticker_url'] = $data['sticker_url'];
      }
      // Create story container
      $response = wp_remote_post(
         $this->getApiUrl('media', 'instagram'),
         ['body' => $params]
      );
      if (is_wp_error($response)) {
         return [
            'success' => false,
            'message' => $response->get_error_message()
         ];
      }
      $container = json_decode(wp_remote_retrieve_body($response), true);
      if (empty($container['id'])) {
         return [
            'success' => false,
            'message' => 'Failed to create story container'
         ];
      }
      // Wait for processing
      sleep(5);
      // Publish story
      $published = $this->publishMedia($container['id']);
      return [
         'success' => !empty($published['id']),
         'message' => !empty($published['id']) ? 'Story published' : 'Failed to publish story',
         'story_id' => $published['id'] ?? null
      ];
   }
   private function handleCommentWebhook(array $value): void
   {
      // Log new comments for moderation
      $this->logDebug('New Instagram comment', $value);
      // Could trigger notifications or store for moderation
      do_action(BASE . 'instagram_comment_received', $value);
   }
   private function handleMentionWebhook(array $value): void
   {
      // Log mentions
      $this->logDebug('New Instagram mention', $value);
      // Could trigger notifications
      do_action(BASE . 'instagram_mention_received', $value);
   }
   /**
    * Get service description
    */
   public function getServiceDescription(): string
   {
      return "Connect your Instagram Business account to automatically share your content and manage your Instagram presence.";
      // TODO: Implement handleResponse() method.
      return [];
   }
}