509318396
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
<?php

namespace PixelYourSite;

use PixelYourSite\OpenAI\Helpers;

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

require_once PYS_FREE_PATH . '/modules/openai/openai-server-async-task.php';

/**
 * OpenAI Ads Conversions API sender (Free edition).
 *
 * Endpoint contract:
 *   POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID>
 *   Authorization: Bearer <conversions api key>
 *   { "integration_source": "...", "validate_only": bool, "events": [ { ... } ] }
 */
class OpenAIServer {

    /** Conversions API endpoint. The pixel id goes in `?pid=`, not the body. */
    const API_URL = 'https://bzr.openai.com/v1/events';

    /** Stable identifier for the sending integration, reported to OpenAI. */
    const INTEGRATION_SOURCE = 'pixelyoursite';

    /** How far in the past `timestamp_ms` may be — 7 days, per the docs. */
    const MAX_EVENT_AGE = 604800;

    /** How far in the future `timestamp_ms` may be — 10 minutes, per the docs. */
    const MAX_EVENT_FUTURE = 600;

    /** Delivered to at least one pixel. */
    const SEND_SENT = 'sent';

    /** Nothing to send: disabled, no token, no recipient. Not an error. */
    const SEND_SKIPPED = 'skipped';

    /** The endpoint refused this payload and would refuse it again. */
    const SEND_FAILED = 'failed';

    /**
     * Event names the Conversions API accepts.
     */
    const SUPPORTED_EVENTS = array(
        'appointment_scheduled',
        'checkout_started',
        'contents_viewed',
        'items_added',
        'lead_created',
        'order_created',
        'page_viewed',
        'registration_completed',
        'subscription_created',
        'trial_started',
        'custom',
    );

    private static $_instance;

    private $isEnabled;
    private $access_token;

    public static function instance() {

        if ( is_null( self::$_instance ) ) {
            self::$_instance = new self();
        }

        return self::$_instance;
    }

    public function __construct() {
        add_action( 'init', array( $this, 'init' ) );
    }

    /**
     * Register the fallback paths the browser can use when the inline server
     * send is unavailable — blocked by consent, delayed, or a dynamic event.
     */
    public function init() {

        $this->isEnabled = OpenAI()->enabled() && OpenAI()->isServerApiEnabled();

        if ( ! $this->isEnabled ) {
            return;
        }

        add_action( 'wp_ajax_pys_openai_api_event', array( $this, 'catchAjaxEvent' ) );
        add_action( 'wp_ajax_nopriv_pys_openai_api_event', array( $this, 'catchAjaxEvent' ) );

        new OpenAIAsyncTask();
    }

    /**
     * Send through the async task, so the visitor's page is not held open.
     *
     * @param SingleEvent[] $events
     */
    public function sendEventsAsync( $events ) {

        $this->snapshotSourceUrlOnEvents( $events );

        $serverEvents = array();

        foreach ( $events as $event ) {

            if ( ! $this->isEventSupported( $event ) ) {
                continue;
            }

            $ids = $event->payload['pixelIds'] ?? array();

            if ( empty( $ids ) ) {
                continue;
            }

            $serverEvents[] = array(
                'pixelIds' => $ids,
                'event'    => $this->mapEventToServerEvent( $event ),
            );
        }

        if ( count( $serverEvents ) > 0 ) {
            do_action( 'pys_send_openai_server_event', $serverEvents );
        }
    }

    /**
     * Send inline, inside the current request.
     *
     * @param SingleEvent[] $events
     */
    public function sendEventsNow( $events ) {

        $this->snapshotSourceUrlOnEvents( $events );

        foreach ( $events as $event ) {

            if ( ! $this->isEventSupported( $event ) ) {
                continue;
            }

            $ids = $event->payload['pixelIds'] ?? array();

            if ( empty( $ids ) ) {
                continue; // no destination pixel
            }

            $this->sendEvent( $ids, $this->mapEventToServerEvent( $event ) );
        }
    }

    /**
     * Record the page each event happened on, before the request ends.
     *
     * @param SingleEvent[] $events
     */
    private function snapshotSourceUrlOnEvents( $events ) {

        if ( empty( $events ) ) {
            return;
        }

        $url = $this->currentPageUrl();

        if ( $url === '' ) {
            return;
        }

        foreach ( $events as $event ) {
            if ( $event instanceof SingleEvent && empty( $event->getPayloadValue( 'event_source_url' ) ) ) {
                $event->addPayload( array( 'event_source_url' => $url ) );
            }
        }
    }

    /**
     * Only the documented event names may go out. An unknown name would be
     * rejected by the endpoint anyway; dropping it here keeps the log readable
     * and costs nothing.
     *
     * @param SingleEvent $event
     * @return bool
     */
    public function isEventSupported( $event ) {

        if ( ! $event instanceof SingleEvent ) {
            return false;
        }

        $name = $event->getPayloadValue( 'name' );

        if ( ! is_string( $name ) || ! in_array( $name, self::SUPPORTED_EVENTS, true ) ) {
            return false;
        }

        if ( $name === 'custom' ) {
            $params = is_array( $event->params ) ? $event->params : array();

            return ! empty( $params['custom_event_name'] );
        }

        return true;
    }

    /**
     * Build the Conversions API event object.
     *
     * @param SingleEvent $event
     * @return \stdClass
     */
    public function mapEventToServerEvent( $event ) {

        $eventData = $event->getData();

        $eventData = EventsManager::filterEventParams( $eventData, $event->getCategory(), array(
            'event_id' => $event->getId(),
            'pixel'    => OpenAI()->getSlug(),
        ) );

        $wooOrder = $event->payload['woo_order'] ?? null;
        $eddOrder = $event->payload['edd_order'] ?? null;

        $serverEvent = new \stdClass;

        $event_id = (string) ( $event->payload['eventID'] ?? '' );

        if ( $event_id === '' ) {
            $event_id = EventIdGenerator::guidv4();
        }

        $serverEvent->id            = $event_id;
        $serverEvent->type          = (string) $eventData['name'];
        $serverEvent->timestamp_ms  = $this->currentTimestampMs();
        $serverEvent->action_source = 'web';
        $serverEvent->source_url    = $this->resolveSourceUrl( $event );

        if ( OpenAI()->getConsentMode() ) {
            $serverEvent->opt_out = true;
        }

        $params = is_array( $eventData['params'] ) ? $eventData['params'] : array();

        if ( $serverEvent->type === 'custom' && ! empty( $params['custom_event_name'] ) ) {
            $serverEvent->custom_event_name = (string) $params['custom_event_name'];
        }

        $this->persistClickId();

        $click_id = Helpers\pys_openai_get_click_id();

        if ( $click_id !== '' ) {
            $serverEvent->oppref = $click_id;
        }

        $userData = $this->getUserData( $wooOrder, $eddOrder );

        if ( count( get_object_vars( $userData ) ) > 0 ) {
            $serverEvent->user = $userData;
        }

        $serverEvent->data = $this->normaliseData( $params['data'] ?? array( 'type' => 'contents' ) );

        return $serverEvent;
    }

    /**
     * Coerce the data object to the types the endpoint demands.
     *
     * @param array|object $data
     * @return \stdClass
     */
    private function normaliseData( $data ) {

        $data = (array) $data;

        if ( isset( $data['type'] ) ) {
            $data['type'] = (string) $data['type'];
        }

        if ( isset( $data['amount'] ) ) {
            $data['amount'] = (int) $data['amount'];
        }

        if ( isset( $data['currency'] ) ) {
            $data['currency'] = (string) $data['currency'];
        }

        if ( isset( $data['plan_id'] ) ) {
            $data['plan_id'] = (string) $data['plan_id'];
        }

        if ( ! empty( $data['contents'] ) && is_array( $data['contents'] ) ) {

            $contents = array();

            foreach ( $data['contents'] as $item ) {

                $item = (array) $item;

                foreach ( array( 'id', 'name', 'content_type', 'currency' ) as $string_field ) {
                    if ( isset( $item[ $string_field ] ) ) {
                        $item[ $string_field ] = (string) $item[ $string_field ];
                    }
                }

                foreach ( array( 'quantity', 'amount' ) as $int_field ) {
                    if ( isset( $item[ $int_field ] ) ) {
                        $item[ $int_field ] = (int) $item[ $int_field ];
                    }
                }

                if ( ! empty( $item ) ) {
                    $contents[] = $item;
                }
            }

            // And an empty list says nothing -- send no key rather than `[]`.
            if ( empty( $contents ) ) {
                unset( $data['contents'] );
            } else {
                $data['contents'] = $contents;
            }
        }

        return (object) $data;
    }

    const MATCHING_FIELD_NAMES = array(
        'email_sha256'        => 'emails_sha256',
        'phone_number_sha256' => 'phone_numbers_sha256',
        'external_id_sha256'  => 'external_ids_sha256',
        'first_name_sha256'   => 'first_names_sha256',
        'last_name_sha256'    => 'last_names_sha256',
        'country'             => 'countries',
        'city'                => 'cities',
        'region'              => 'regions',
        'postal_code'         => 'postal_codes',
    );

    /**
     * The event's `user` object — every field OpenAI accepts and nothing else.
     *
     * @param int|null $wooOrder
     * @param int|null $eddOrder
     * @return \stdClass
     */
    public function getUserData( $wooOrder = null, $eddOrder = null ) {

        $userData = new \stdClass;

        $ip = $this->getIpAddress();

        if ( $ip !== '' ) {
            $userData->ip_address = $ip;
        }

        $user_agent = $this->getHttpUserAgent();

        if ( $user_agent !== '' ) {
            $userData->user_agent = $user_agent;
        }

        if ( ! empty( $_COOKIE['__obref'] ) ) {

            $obref = sanitize_text_field( wp_unslash( $_COOKIE['__obref'] ) );

            if ( $obref !== '' ) {
                $userData->obref = $obref;
            }
        }

        if ( OpenAI()->getOption( 'advanced_matching_enabled' ) ) {

            foreach ( OpenAI()->getServerMatchingValues( $wooOrder, $eddOrder ) as $field => $values ) {

                $name = isset( self::MATCHING_FIELD_NAMES[ $field ] ) ? self::MATCHING_FIELD_NAMES[ $field ] : null;

                if ( null === $name || empty( $values ) ) {
                    continue;
                }

                $userData->$name = array_values( $values );
            }

        } elseif ( EventsManager::isTrackExternalId() ) {
            $external_id = OpenAI()->getExternalId( $wooOrder, $eddOrder );

            if ( $external_id !== '' ) {
                $userData->external_ids_sha256 = array( hash( 'sha256', $external_id ) );
            }
        }

        return apply_filters( 'pys_openai_server_user_data', $userData, $wooOrder, $eddOrder );
    }

    /**
     * Persist OpenAI's click identifier so later events keep reporting it.
     */
    private function persistClickId() {

        if ( ! empty( $_COOKIE['__oppref'] ) || headers_sent() ) {
            return;
        }

        if ( ! Consent()->checkConsent( 'openai' ) ) {
            return;
        }

        $from_url = Helpers\pys_openai_get_click_id();

        if ( $from_url === '' ) {
            return;
        }

        // Not stored when the site switched every PYS cookie off; the rest of
        // this request still reads it from $_COOKIE.
        if ( ! pys_cookie_domain_cookies_disabled() ) {
            setcookie( '__oppref', $from_url, time() + Helpers\pys_openai_cookie_lifetime(), '/', pys_cookie_domain() );
        }
        $_COOKIE['__oppref'] = $from_url;
    }

    /**
     * Send one mapped event to every recipient pixel.
     *
     * @param array     $pixel_Ids Recipient pixel ids, already resolved upstream.
     * @param \stdClass $event     One mapped Conversions API event.
     * @return array{status:string,message:string}
     */
    public function sendEvent( $pixel_Ids, $event ) {

        if ( ! $event ) {
            return $this->sendResult( self::SEND_SKIPPED, 'No event to send' );
        }

        if ( ! OpenAI()->enabled() || ! OpenAI()->isServerApiEnabled() ) {
            return $this->sendResult( self::SEND_SKIPPED, 'OpenAI pixel or its Conversions API is disabled' );
        }

        if ( ! $this->isTimestampSendable( $event->timestamp_ms ?? null ) ) {

            PYS()->getLog()->debug( 'OpenAI server event skipped - timestamp outside the accepted window' );

            return $this->sendResult( self::SEND_FAILED, 'Event timestamp is outside the window the endpoint accepts' );
        }

        if ( ! $this->access_token ) {
            $this->access_token = OpenAI()->getApiTokens();
        }

        $validate_only = (bool) OpenAI()->getOption( 'server_validate_only' );

        $sent           = false;
        $failed_message = '';

        foreach ( (array) $pixel_Ids as $pixel_Id ) {

            if ( empty( $pixel_Id ) || empty( $this->access_token[ $pixel_Id ] ) ) {
                continue;
            }

            $request = $this->buildRequest( $pixel_Id, $event, $validate_only );

            PYS()->getLog()->debug(
                'Send OpenAI server event ' . $event->type . ' to pixel ' . $pixel_Id,
                $this->logSafeRequest( $request )
            );

            $response = wp_remote_post( $request['url'], array(
                'headers'     => $request['headers'],
                'body'        => $request['body'],
                'timeout'     => pys_server_request_timeout(),
                'redirection' => 0,
                'blocking'    => true,
            ) );

            if ( is_wp_error( $response ) ) {

                PYS()->getLog()->error( 'Error send OpenAI server event: ' . $response->get_error_message() );

                $failed_message = sprintf( 'OpenAI pixel %s: %s', $pixel_Id, $response->get_error_message() );
                continue;
            }

            $status = (int) wp_remote_retrieve_response_code( $response );
            $raw    = (string) wp_remote_retrieve_body( $response );

            PYS()->getLog()->debug( 'Response from OpenAI server: ' . $status . ' ' . $raw );

            if ( $status < 200 || $status >= 300 ) {
                PYS()->getLog()->error(
                    'OpenAI rejected the request with HTTP ' . $status . ': ' . $raw,
                    $this->logSafeRequest( $request )
                );

                $failed_message = sprintf( 'OpenAI pixel %s: HTTP %d', $pixel_Id, $status );
                continue;
            }

            $body     = json_decode( $raw, true );
            $accepted = is_array( $body ) && isset( $body['accepted_events'] )
                ? (int) $body['accepted_events']
                : 1;

            if ( $accepted < 1 ) {

                PYS()->getLog()->error(
                    'OpenAI accepted no events: ' . $raw,
                    $this->logSafeRequest( $request )
                );

                $failed_message = sprintf( 'OpenAI pixel %s: accepted_events 0', $pixel_Id );
                continue;
            }

            $sent = true;
        }

        if ( $failed_message !== '' ) {
            return $this->sendResult( self::SEND_FAILED, $failed_message );
        }

        return $sent
            ? $this->sendResult( self::SEND_SENT )
            : $this->sendResult( self::SEND_SKIPPED, 'No pixel ID with a configured Conversions API key' );
    }

    /**
     * The browser's AJAX fallback, used when the REST route is unreachable.
     */
    public function catchAjaxEvent() {

        if ( ! OpenAI()->isServerApiEnabled() ) {
            wp_send_json_error( 'OpenAI Conversions API is disabled', 400 );
        }

        $event_name = isset( $_POST['event'] ) ? sanitize_text_field( wp_unslash( $_POST['event'] ) ) : '';
        $ids        = isset( $_POST['ids'] ) ? (array) wp_unslash( $_POST['ids'] ) : array();

        if ( $event_name === '' || empty( $ids ) ) {
            wp_send_json_error( 'Missing mandatory parameters: event and ids', 400 );
        }

        $params   = isset( $_POST['data'] ) ? map_deep( wp_unslash( $_POST['data'] ), 'sanitize_text_field' ) : array();

        if ( isset( $_POST['event_id'] ) ) {
            $event_id = sanitize_text_field( wp_unslash( $_POST['event_id'] ) );
        } elseif ( isset( $_POST['eventID'] ) ) {
            $event_id = sanitize_text_field( wp_unslash( $_POST['eventID'] ) );
        } else {
            $event_id = '';
        }

        $single_event = $this->dataToSingleEvent(
            $event_name,
            is_array( $params ) ? $params : array(),
            $event_id,
            array_map( 'sanitize_text_field', $ids ),
            isset( $_POST['woo_order'] ) ? (int) $_POST['woo_order'] : 0,
            isset( $_POST['edd_order'] ) ? (int) $_POST['edd_order'] : 0,
            isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''
        );

        $this->sendEventsNow( array( $single_event ) );

        wp_send_json_success();
    }

    /**
     * Rebuild a SingleEvent from what the browser posted.
     *
     * @param string $event_name
     * @param array  $params
     * @param string $event_id
     * @param array  $ids
     * @param int    $woo_order
     * @param int    $edd_order
     * @param string $source_url
     * @return SingleEvent
     */
    public function dataToSingleEvent( $event_name, $params, $event_id, $ids, $woo_order = 0, $edd_order = 0, $source_url = '' ) {

        $single_event = new SingleEvent( '', '' );

        $payload = array(
            'name'      => $event_name,
            'eventID'   => $event_id,
            'woo_order' => $woo_order,
            'edd_order' => $edd_order,
            'pixelIds'  => $ids,
        );

        if ( $source_url !== '' && wp_http_validate_url( $source_url ) ) {
            $payload['event_source_url'] = $source_url;
        }

        $single_event->addParams( $params );
        $single_event->addPayload( $payload );

        return $single_event;
    }

    /**
     * Resolve `source_url`, which the endpoint requires for every web event.
     *
     * @param SingleEvent $event
     * @return string
     */
    private function resolveSourceUrl( $event ) {

        $from_browser = $event->getPayloadValue( 'event_source_url' );

        if ( is_string( $from_browser ) && $from_browser !== '' && wp_http_validate_url( $from_browser ) ) {
            return $this->stripSourceUrlParams( $from_browser );
        }

        $current = $this->currentPageUrl();

        if ( $current !== '' ) {
            return $this->stripSourceUrlParams( $current );
        }

        return home_url( '/' );
    }

    /**
     * Honour the plugin-wide "remove source URL parameters" setting.
     *
     * @param string $url
     * @return string
     */
    private function stripSourceUrlParams( $url ) {

        if ( ! PYS()->getOption( 'enable_remove_source_url_params' ) ) {
            return $url;
        }

        $parts = explode( '?', $url );

        return $parts[0] !== '' ? $parts[0] : $url;
    }

    /**
     * URL of the page this request is for.
     *
     * @return string
     */
    private function currentPageUrl() {

        if ( empty( $_SERVER['HTTP_HOST'] ) || empty( $_SERVER['REQUEST_URI'] ) ) {
            return '';
        }

        $url = ( is_ssl() ? 'https://' : 'http://' )
            . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) )
            . esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );

        return wp_http_validate_url( $url ) ? $url : '';
    }

    /**
     * Assemble the HTTP request for one recipient pixel.
     *
     * @param string    $pixel_Id
     * @param \stdClass $event
     * @param bool      $validate_only
     * @return array{url:string,headers:array,body:string}
     */
    public function buildRequest( $pixel_Id, $event, $validate_only = false ) {

        if ( ! $this->access_token ) {
            $this->access_token = OpenAI()->getApiTokens();
        }

        $body = array(
            'integration_source' => self::INTEGRATION_SOURCE,
            'validate_only'      => (bool) $validate_only,
            'events'             => array( $event ),
        );

        return array(
            'url'     => self::API_URL . '?pid=' . rawurlencode( $pixel_Id ),
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . ( $this->access_token[ $pixel_Id ] ?? '' ),
            ),
            'body'    => wp_json_encode( $body ),
        );
    }

    /**
     * The request as it goes on the wire, in a form that is safe to write to a
     * log the admin can download.
     *
     * @param array $request
     * @return array
     */
    private function logSafeRequest( $request ) {

        $body = json_decode( isset( $request['body'] ) ? $request['body'] : '', true );

        return array(
            'url'  => isset( $request['url'] ) ? $request['url'] : '',
            'body' => $body === null ? ( $request['body'] ?? '' ) : $body,
        );
    }

    /**
     * Current time as an integer millisecond timestamp.
     *
     * @return int
     */
    private function currentTimestampMs() {
        return (int) round( microtime( true ) * 1000 );
    }

    /**
     * The endpoint accepts `timestamp_ms` no older than 7 days and no more than
     * 10 minutes ahead.
     *
     * @param mixed $timestamp_ms
     * @return bool
     */
    public function isTimestampSendable( $timestamp_ms ) {

        if ( ! is_numeric( $timestamp_ms ) ) {
            return false;
        }

        $age = ( $this->currentTimestampMs() - (int) $timestamp_ms ) / 1000;

        return $age <= self::MAX_EVENT_AGE && $age >= -self::MAX_EVENT_FUTURE;
    }

    /**
     * The visitor's IP, or '' when there is none worth reporting.
     *
     * @return string
     */
    private function getIpAddress() {
        // This had the right instinct already — take PYS()->get_user_ip() and
        // reject anything non-routable. pys_client_ip_for_capi() does exactly
        // that, and it reads the Cloudflare and nginx headers this plugin
        // previously ignored.
        return pys_client_ip_for_capi();
    }

    /**
     * @return string
     */
    private function getHttpUserAgent() {

        if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
            return '';
        }

        return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
    }

    /**
     * @param string $status One of the SEND_* constants.
     * @param string $message
     * @return array{status:string,message:string}
     */
    private function sendResult( $status, $message = '' ) {
        return array( 'status' => $status, 'message' => $message );
    }
}

/**
 * @return OpenAIServer
 */
function OpenAIServer() {
    return OpenAIServer::instance();
}

OpenAIServer();
