<?php
|
/**
|
* Facebook Integration
|
* Handles Facebook posts, page information, events, and OAuth authentication
|
*/
|
|
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\Sanitizer;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class Facebook extends Integrations
|
{
|
use Admin,
|
Instance,
|
OAuth,
|
UserConnection,
|
Requests,
|
SyncFrom,
|
SyncTo,
|
SyncDefaults;
|
|
protected function __construct()
|
{
|
$this->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;
|
}
|
}
|