connection = $connection; } /** * Sends a request. * * @param string $method HTTP method. * @param string $path Path under the API base, starting with /v1/. * @param array $args query (array), body (array, sent as JSON), timeout (int), * multipart (array{name: string, filename: string, type: string, contents: string}). * @return array|WP_Error Decoded JSON (empty array for 204). */ public function request( string $method, string $path, array $args = array() ) { if ( null !== $this->unreachable ) { return $this->unreachable; } $backoff = get_transient( self::BACKOFF_TRANSIENT ); if ( $backoff && (int) $backoff > time() ) { return new WP_Error( 'stand_rate_limited', __( 'Stand asked WordPress to slow down. Try again in a minute.', 'stand-chat' ), array( 'status' => 429, 'retry_after' => (int) $backoff - time(), ) ); } $url = Service_Urls::get( 'server_api' ) . $path; if ( ! empty( $args['query'] ) && is_array( $args['query'] ) ) { $url = add_query_arg( array_map( 'rawurlencode', array_map( 'strval', $args['query'] ) ), $url ); } $headers = array( 'Accept' => 'application/json', 'User-Agent' => self::user_agent(), ); $token = $this->connection->access_token( ! empty( $args['renew'] ) ); // The session the token is from: Stand turning it away marks only it. $session = $this->connection->token_session(); if ( null === $token || '' === $token ) { $cause = $this->connection->token_error(); if ( null !== $cause && 'stand_unreachable' === $cause->get_error_code() ) { $this->unreachable = $cause; return $cause; } return new WP_Error( 'stand_reconnect_needed', self::reconnect_message(), array( 'status' => 401 ) ); } $headers['Authorization'] = 'Bearer ' . $token; $body = null; if ( isset( $args['multipart'] ) && is_array( $args['multipart'] ) ) { $boundary = 'stand-chat-' . wp_generate_password( 24, false ); $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; $part = $args['multipart']; $body = '--' . $boundary . "\r\n" . 'Content-Disposition: form-data; name="' . $part['name'] . '"; filename="' . $part['filename'] . "\"\r\n" . 'Content-Type: ' . $part['type'] . "\r\n\r\n" . $part['contents'] . "\r\n" . '--' . $boundary . "--\r\n"; } elseif ( array_key_exists( 'body', $args ) ) { $headers['Content-Type'] = 'application/json'; // Stand's bodies are objects: an empty PHP array would encode as [], // which Stand turns away as a malformed body. $body = wp_json_encode( array() === $args['body'] ? new \stdClass() : $args['body'] ); } $response = wp_remote_request( $url, array( 'method' => strtoupper( $method ), 'headers' => $headers, 'body' => $body, 'timeout' => (int) ( $args['timeout'] ?? 15 ), 'redirection' => 0, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { $this->remember_error( 'stand_unreachable', $method, $path, 0 ); $this->unreachable = new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ), array( 'status' => 0, 'reason' => $response->get_error_message(), ) ); return $this->unreachable; } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $decoded = '' === $raw ? array() : json_decode( $raw, true ); if ( $status >= 200 && $status < 300 ) { // Stand answers with JSON, or with no content (204) to a removal. if ( 204 !== $status && ( '' === $raw || ! is_array( $decoded ) ) ) { // Not Stand's JSON, as from a proxy or a sign-in page in between: // Stand wasn't reached, rather than answered with nothing. $this->remember_error( 'not_json', $method, $path, $status ); $this->unreachable = new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ), array( 'status' => $status, 'reason' => 'not_json', ) ); return $this->unreachable; } return is_array( $decoded ) ? $decoded : array(); } $error = is_array( $decoded ) && isset( $decoded['error'] ) && is_array( $decoded['error'] ) ? $decoded['error'] : array(); $code = isset( $error['code'] ) ? (string) $error['code'] : ''; $message = isset( $error['message'] ) ? (string) $error['message'] : ''; $data = array( 'status' => $status, 'stand_code' => $code, 'details' => isset( $error['details'] ) && is_array( $error['details'] ) ? $error['details'] : array(), ); $this->remember_error( $code ? $code : 'http_' . $status, $method, $path, $status ); switch ( true ) { case 401 === $status: if ( empty( $args['renew'] ) ) { // The access token may have ended early: once more with a new one. return $this->request( $method, $path, array_merge( $args, array( 'renew' => true ) ) ); } $this->connection->mark_reconnect_needed( $code ? $code : 'unauthorized', $session ); return new WP_Error( 'stand_reconnect_needed', self::reconnect_message(), $data ); // The connected person was removed from the organization, or turned // off there: only connecting another account helps. case 403 === $status && 'ACTIVE_ORGANIZATION_MEMBERSHIP_REQUIRED' === $code: $this->connection->mark_reconnect_needed( $code, $session ); return new WP_Error( 'stand_reconnect_needed', self::reconnect_message(), $data ); case 403 === $status && 'INSUFFICIENT_SCOPE' === $code: return new WP_Error( 'stand_insufficient_scope', __( 'Stand Chat isn\'t allowed to do this. Reconnect and allow it in the Stand window.', 'stand-chat' ), $data ); case 403 === $status && 'OWNER_REQUIRED' === $code: return new WP_Error( 'stand_owner_only', '' !== $message ? $message : __( "Only your organization's owner can change this.", 'stand-chat' ), $data ); // The plugin refuses the same before it writes (Behavior_Rules::prepare), // so this answers custom JavaScript set in Stand meanwhile. case 403 === $status && 'CUSTOM_JAVASCRIPT_NOT_ALLOWED' === $code: return new WP_Error( 'stand_script_rule', Behavior_Rules::custom_javascript_message(), $data ); // Other denials, such as FORBIDDEN, keep their own reason. case 403 === $status: return new WP_Error( 'stand_forbidden', '' !== $message ? $message : __( 'Stand didn\'t allow this.', 'stand-chat' ), $data ); case 402 === $status: return new WP_Error( 'stand_plan_limit', '' !== $message ? $message : __( 'Your plan doesn\'t include this. See plans to upgrade.', 'stand-chat' ), $data ); // A route this Stand version doesn't have answers without Stand's error body. case 404 === $status && empty( $error ): case 405 === $status: case 501 === $status: return new WP_Error( 'stand_unavailable', __( 'This Stand feature isn\'t available yet. Update Stand Chat or try again later.', 'stand-chat' ), $data ); case 404 === $status: return new WP_Error( 'stand_not_found', '' !== $message ? $message : __( 'Stand couldn\'t find that.', 'stand-chat' ), $data ); case 409 === $status: return new WP_Error( 'stand_conflict', '' !== $message ? $message : __( 'This changed in Stand meanwhile. Reload and try again.', 'stand-chat' ), $data ); case 429 === $status: $retry = absint( wp_remote_retrieve_header( $response, 'retry-after' ) ); $retry = $retry > 0 ? min( $retry, 600 ) : 60; $data['retry_after'] = $retry; set_transient( self::BACKOFF_TRANSIENT, time() + $retry, $retry ); return new WP_Error( 'stand_rate_limited', __( 'Stand asked WordPress to slow down. Try again in a minute.', 'stand-chat' ), $data ); case $status >= 400 && $status < 500: return new WP_Error( 'stand_invalid', '' !== $message ? $message : __( 'Stand didn\'t accept this change.', 'stand-chat' ), $data ); // A gateway in front of Stand answered: Stand itself wasn't reached. case in_array( $status, array( 502, 503, 504 ), true ): $this->unreachable = new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ), $data ); return $this->unreachable; default: return new WP_Error( 'stand_server_error', __( 'Stand had a problem. Try again in a moment.', 'stand-chat' ), $data ); } } /** * User-Agent: lets Stand see which plugin versions still call an endpoint. */ public static function user_agent(): string { global $wp_version; return sprintf( 'StandChat-WordPress/%s WordPress/%s PHP/%s', STAND_CHAT_VERSION, $wp_version, PHP_VERSION ); } /** * Whether an error says Stand is down, failing or busy for now, rather than * answering: what WordPress last knew still holds. * * @param WP_Error $error An error from request() or Stand. */ public static function is_temporary( WP_Error $error ): bool { return in_array( $error->get_error_code(), array( 'stand_unreachable', 'stand_server_error', 'stand_rate_limited', 'stand_busy' ), true ); } /** * Copy for a lost connection. */ public static function reconnect_message(): string { return __( 'Stand Chat lost its connection to your Stand account. Reconnect to manage your AI assistant here. The chat on your site keeps working.', 'stand-chat' ); } /** * Remembers the last API error for "Information for support" (no secrets). * * @param string $code Error code. * @param string $method HTTP method. * @param string $path Request path. * @param int $status HTTP status. */ private function remember_error( string $code, string $method, string $path, int $status ): void { State::set( 'last_api_error', array( 'code' => substr( $code, 0, 60 ), 'call' => strtoupper( $method ) . ' ' . preg_replace( '/[0-9a-f]{8}-[0-9a-f-]{27,}/i', '{id}', strtok( $path, '?' ) ), 'status' => $status, 'at' => time(), ) ); } }