65535 ) { return ''; } $origin .= ':' . $port; } return $origin; } /** * Is the host this machine? * * @param string $host Lower-case host name. * @return bool */ private static function is_loopback_host( $host ) { if ( 'localhost' === $host || '.localhost' === substr( $host, -10 ) ) { return true; } return (bool) preg_match( '/^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/', $host ); } /** * The chatbots this token may embed. * * @param string $api_key Personal access token. * @return array|WP_Error `array{chatbots: array, has_more: bool}` — * `has_more` when the account has more agents * than {@see CHATBOTS_PER_PAGE} — or an error. */ public static function list_chatbots( $api_key ) { $response = self::request( 'GET', '/plugins/chatbots?per_page=' . self::CHATBOTS_PER_PAGE, $api_key ); if ( is_wp_error( $response ) ) { return $response; } return array( 'chatbots' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(), 'has_more' => ! empty( $response['has_more'] ), ); } /** * Connect this site to a chatbot. * * @param string $api_key Personal access token. * @param int $chatbot_id Chatbot to embed. * @return array|WP_Error Install payload, or an error. */ public static function install( $api_key, $chatbot_id ) { $response = self::request( 'POST', '/plugins/install', $api_key, array( 'chatbot_id' => (int) $chatbot_id, 'platform' => 'wordpress', 'site_url' => self::site_url(), 'plugin_version' => AGENTENCY_CHATBOT_VERSION, ) ); if ( is_wp_error( $response ) ) { return $response; } return isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); } /** * Report that this site is still live, and on which version. * * @param string $api_key Personal access token. * @param int $installation_id Installation id from the install call. * @return array|WP_Error */ public static function heartbeat( $api_key, $installation_id ) { return self::request( 'POST', '/plugins/heartbeat', $api_key, array( 'installation_id' => (int) $installation_id, 'plugin_version' => AGENTENCY_CHATBOT_VERSION, ) ); } /** * Disconnect this site, releasing its allowlist entry. * * @param string $api_key Personal access token. * @param int $installation_id Installation id. * @return array|WP_Error */ public static function uninstall( $api_key, $installation_id ) { return self::request( 'DELETE', '/plugins/install/' . (int) $installation_id, $api_key ); } /** * The origin this site reports to the API. * * Derived from `home_url()` — the address WordPress itself considers * canonical — never from `$_SERVER['HTTP_HOST']`, which a reverse proxy or * a Host-header probe can set to anything and which would register the * wrong domain in the allowlist. * * @return string */ public static function site_url() { return untrailingslashit( home_url() ); } /** * Perform a request. * * `reject_unsafe_urls` makes core refuse private, reserved and odd-port * targets, and `redirection => 0` stops a 3xx from carrying the Bearer * token to a host the policy never saw. A local Agentency on a loopback * address therefore needs core's `http_request_host_is_external` (and, * off ports 80 / 443 / 8080, `http_allowed_safe_ports`) filters. * * @param string $method HTTP verb. * @param string $path Path beginning with a slash. * @param string $api_key Bearer credential. * @param array|null $body Optional JSON body. * @return array|WP_Error Decoded response, or an error. */ private static function request( $method, $path, $api_key, $body = null ) { if ( ! is_string( $api_key ) || '' === trim( $api_key ) ) { return new WP_Error( 'agentency_missing_key', __( 'Add your Agentency personal access token first.', 'agentency-chatbot' ) ); } $base_url = self::base_url(); if ( '' === $base_url ) { return new WP_Error( 'agentency_invalid_api_url', __( 'The API URL in these settings is not allowed. It must start with https:// (http:// only for localhost). Correct it or clear it to use Agentency.', 'agentency-chatbot' ) ); } $args = array( 'method' => $method, 'timeout' => self::TIMEOUT, 'redirection' => 0, 'reject_unsafe_urls' => true, 'headers' => array( 'Authorization' => 'Bearer ' . trim( $api_key ), 'Accept' => 'application/json', 'User-Agent' => 'AgentencyWordPress/' . AGENTENCY_CHATBOT_VERSION . '; ' . self::site_url(), ), ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $base_url . $path, $args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'agentency_transport_error', sprintf( /* translators: %s: transport error message from WordPress. */ __( 'Could not reach Agentency: %s', 'agentency-chatbot' ), $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $decoded ) ) { $decoded = array(); } if ( $status >= 200 && $status < 300 ) { return $decoded; } return new WP_Error( 'agentency_api_error', self::error_message( $status, $decoded ), array( 'status' => $status ) ); } /** * Turn an error response into copy a site admin can act on. * * A 401 always gets this plugin's own copy: the server's text for it * ("Unauthenticated.") says nothing about which credential to create * instead. Otherwise the API's message is preferred — it explains the * specific failure (domain cap reached, plan without integrations) far * better than a status-code mapping — reduced to one line of plain text * of bounded length, since a self-hosted URL means the server is not * necessarily Agentency's. * * @param int $status HTTP status. * @param array $decoded Decoded body. * @return string */ private static function error_message( $status, array $decoded ) { $token_rejected = __( 'Your personal access token was rejected. In Agentency, open Developers → Tokens, create a token with only the "widget:manage" scope, then paste it here.', 'agentency-chatbot' ); if ( 401 === $status ) { return $token_rejected; } if ( isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { $message = sanitize_text_field( $decoded['message'] ); if ( '' !== $message ) { return mb_substr( $message, 0, self::ERROR_MESSAGE_MAX_LENGTH ); } } if ( 403 === $status ) { return $token_rejected; } if ( $status >= 300 && $status < 400 ) { return __( 'Agentency answered with a redirect, which this plugin does not follow. Check the API URL in these settings.', 'agentency-chatbot' ); } if ( 404 === $status ) { return __( 'That agent no longer exists in your Agentency account.', 'agentency-chatbot' ); } if ( 429 === $status ) { return __( 'Too many requests. Wait a moment and try again.', 'agentency-chatbot' ); } return sprintf( /* translators: %d: HTTP status code. */ __( 'Agentency returned an unexpected error (HTTP %d).', 'agentency-chatbot' ), $status ); } }