connection = $connection; $this->stand = $stand; $this->knowledge = new Knowledge( $stand ); } /** * Hooks. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); } /** * Registers the routes. */ public function routes(): void { $manage = array( $this, 'can_manage' ); $routes = array( array( '/state', 'GET', 'get_state' ), array( '/settings', 'POST', 'post_settings' ), array( '/site-id', 'POST', 'post_site_id' ), array( '/site-id', 'DELETE', 'delete_site_id' ), array( '/connect/start', 'POST', 'post_connect_start' ), array( '/connect/poll', 'POST', 'post_connect_poll' ), array( '/connect/finish', 'POST', 'post_connect_finish' ), array( '/connect/cancel', 'POST', 'post_connect_cancel' ), array( '/connection', 'GET', 'get_connection' ), array( '/disconnect', 'POST', 'post_disconnect' ), array( '/overview', 'GET', 'get_overview' ), array( '/plan', 'GET', 'get_plan' ), array( '/who-answers', 'POST', 'post_who_answers' ), array( '/go-live', 'POST', 'post_go_live' ), array( '/standins', 'GET', 'get_standins' ), array( '/standins', 'POST', 'post_standin' ), array( '/standins/(?P[A-Za-z0-9_-]+)', 'GET', 'get_standin' ), array( '/standins/(?P[A-Za-z0-9_-]+)', 'PUT', 'put_standin' ), array( '/standins/(?P[A-Za-z0-9_-]+)/enabled', 'POST', 'post_standin_enabled' ), array( '/standins/(?P[A-Za-z0-9_-]+)/skills/(?P[A-Za-z]+)', 'PUT', 'put_skill' ), array( '/standins/(?P[A-Za-z0-9_-]+)/look', 'POST', 'post_look' ), array( '/standins/(?P[A-Za-z0-9_-]+)/knowledge', 'POST', 'post_attach_knowledge' ), array( '/looks', 'POST', 'post_looks' ), array( '/knowledge', 'GET', 'get_knowledge' ), array( '/knowledge/pages', 'POST', 'post_knowledge_pages' ), array( '/knowledge/read-again', 'POST', 'post_read_again' ), array( '/knowledge/tasks/(?P[A-Za-z0-9_-]+)/cancel', 'POST', 'post_cancel_task' ), array( '/knowledge/search', 'POST', 'post_search' ), array( '/knowledge/sitemap', 'POST', 'post_sitemap' ), array( '/knowledge/sitemap-refresh', 'POST', 'post_sitemap_refresh' ), array( '/content', 'GET', 'get_content' ), array( '/behavior', 'GET', 'get_behavior' ), array( '/behavior', 'PUT', 'put_behavior' ), array( '/installation/check', 'POST', 'post_check' ), array( '/site', 'GET', 'get_site' ), array( '/site', 'PUT', 'put_site' ), array( '/site-prompt', 'GET', 'get_site_prompt' ), array( '/site-prompt', 'PUT', 'put_site_prompt' ), array( '/site-prompt', 'DELETE', 'delete_site_prompt' ), array( '/team', 'GET', 'get_team' ), array( '/address', 'POST', 'post_address' ), array( '/notices/(?P[a-z_]+)/dismiss', 'POST', 'post_dismiss' ), array( '/support', 'GET', 'get_support' ), ); // Connecting, and changes kept only in WordPress, aren't any // connection's, and disconnecting checks the page's connection itself, // once nothing else is made on it: every other change is made on the // connection the page was loaded on. $any_connection = array( 'post_settings', 'post_site_id', 'delete_site_id', 'post_connect_start', 'post_connect_poll', 'post_connect_finish', 'post_connect_cancel', 'post_disconnect', 'post_check', 'post_address', 'post_dismiss' ); foreach ( $routes as $route ) { register_rest_route( self::NAMESPACE, $route[0], array( 'methods' => $route[1], 'callback' => 'GET' === $route[1] || in_array( $route[2], $any_connection, true ) ? array( $this, $route[2] ) : $this->on_loaded_connection( $route[2] ), 'permission_callback' => $manage, ) ); } // The block editor's sidebar panel: editors see the page's chat behavior // and knowledge status; changing them takes the plugin's capability. foreach ( array( 'GET' => 'can_edit_post', 'PUT' => 'can_manage', ) as $method => $permission ) { register_rest_route( self::NAMESPACE, '/pages/(?P\d+)', array( 'methods' => $method, 'callback' => 'GET' === $method ? array( $this, 'get_page' ) : $this->on_loaded_connection( 'put_page' ), 'permission_callback' => array( $this, $permission ), ) ); } } /** * A route's callback, made on the connection the page was loaded on, as * its CONNECTION_HEADER says. A page loaded on another connection, such * as before another admin connected WordPress to another Site, is told * to reload rather than change that Site. * * @param string $callback Method name. */ private function on_loaded_connection( string $callback ): callable { return function ( WP_REST_Request $request ) use ( $callback ) { $loaded = $request->get_header( self::CONNECTION_HEADER ); $result = $this->stand->on_loaded_connection( null === $loaded ? null : (string) $loaded, function () use ( $callback, $request ) { return $this->$callback( $request ); } ); return is_wp_error( $result ) ? self::respond( $result ) : $result; }; } /** * Permission: the plugin's capability (manage_options by default). */ public function can_manage(): bool { return current_user_can( Admin::capability() ); } /** * Permission: may edit the post the editor panel is about. * * @param WP_REST_Request $request Request. */ public function can_edit_post( WP_REST_Request $request ): bool { return current_user_can( 'edit_post', (int) $request['post'] ); } // --------------------------------------------------------------------- // State, settings and connection. // --------------------------------------------------------------------- /** * Everything every screen needs on load. */ public function get_state(): WP_REST_Response { return new WP_REST_Response( $this->state() ); } /** * The state payload. * * @return array */ public function state(): array { return array( 'settings' => Settings::all(), 'connection' => $this->connection->summary(), 'setup' => State::setup(), 'environment' => array( 'type' => Environment::type(), 'isProduction' => Environment::is_production(), 'homeDomain' => Environment::home_domain(), 'siteDomain' => Environment::site_domain(), 'mismatch' => Environment::domain_mismatch(), 'hiddenReason' => Environment::hidden_reason(), 'subdirectory' => Environment::is_subdirectory_subsite(), 'addressDecision' => (string) State::get( 'address_decision', '' ), ), 'install' => Installation_Check::last(), 'primaryStandinId' => (string) State::get( 'standin_id', '' ), 'knowledgeBaseId' => (string) State::get( 'knowledge_base_id', '' ), 'notices' => Notices::for_plugin_pages( $this->connection ), 'consent' => array( 'apiActive' => function_exists( 'wp_has_consent' ), 'banner' => self::consent_banner(), ), 'optimizers' => Optimizers::detected(), 'permalinksPlain' => '' === (string) get_option( 'permalink_structure' ), ); } /** * Saves WordPress-side settings. * * @param WP_REST_Request $request Request. */ public function post_settings( WP_REST_Request $request ): WP_REST_Response { $changes = (array) $request->get_json_params(); unset( $changes['site_id'] ); Settings::update( $changes ); if ( array_key_exists( 'chat_enabled', $changes ) && $this->connection->is_usable() ) { Installation_Check::run(); } return new WP_REST_Response( $this->state() ); } /** * Saves a pasted Site ID. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_site_id( WP_REST_Request $request ) { $site_id = Settings::sanitize_site_id( (string) $request->get_param( 'siteId' ) ); if ( '' === $site_id ) { return new WP_Error( 'stand_chat_site_id', __( 'That doesn\'t look like a Site ID. Copy it from the site card in Sites in Stand.', 'stand-chat' ), array( 'status' => 400 ) ); } // Checked and saved under the claim on connecting: a connection made // meanwhile keeps its Site ID. $claim = $this->connection->hold( MINUTE_IN_SECONDS ); if ( '' === $claim ) { return self::respond( Connection::connecting() ); } try { if ( $this->connection->is_connected_now() ) { return new WP_Error( 'stand_chat_connected', __( 'This site is connected to Stand. Disconnect first to use another Site ID.', 'stand-chat' ), array( 'status' => 409 ) ); } Settings::update( array( 'site_id' => $site_id, 'chat_enabled' => true, ) ); } finally { $this->connection->let_go( $claim ); } Installation_Check::run(); return new WP_REST_Response( $this->state() ); } /** * Clears a pasted Site ID. * * @return WP_REST_Response|WP_Error */ public function delete_site_id() { // As when one is pasted: a connection made meanwhile keeps its Site ID. $claim = $this->connection->hold( MINUTE_IN_SECONDS ); if ( '' === $claim ) { return self::respond( Connection::connecting() ); } try { if ( $this->connection->is_connected_now() ) { return new WP_Error( 'stand_chat_connected', __( 'This site is connected to Stand. Disconnect first to remove its Site ID.', 'stand-chat' ), array( 'status' => 409 ) ); } Settings::update( array( 'site_id' => '', 'chat_enabled' => false, ) ); State::forget( 'install_check' ); } finally { $this->connection->let_go( $claim ); } return new WP_REST_Response( $this->state() ); } /** * Starts connecting: the Stand sign-in page to open, and its code. * * @return WP_REST_Response|WP_Error */ public function post_connect_start() { return self::respond( $this->connection->start() ); } /** * Polled while the Stand window is open: connected, waiting, onboarding, * cancelled, expired, failed or none. */ public function post_connect_poll(): WP_REST_Response { $result = $this->connection->poll(); // Live already when connecting picked up the Site's AI assistant. $result['live'] = State::setup()['live']; return new WP_REST_Response( $result ); } /** * Continues connecting once a new Stand account is set up. * * @return WP_REST_Response|WP_Error */ public function post_connect_finish() { $finished = $this->connection->finish(); if ( is_wp_error( $finished ) ) { return self::respond( $finished ); } return new WP_REST_Response( array( 'status' => 'connected', 'live' => State::setup()['live'], ) ); } /** * Cancels connecting, including an account still being set up in Stand. * * @return WP_REST_Response|WP_Error */ public function post_connect_cancel() { $refused = $this->connection->cancel(); if ( null !== $refused ) { return self::respond( $refused ); } return new WP_REST_Response( $this->state() ); } /** * Settings → Connection: re-reads the Site and role from Stand, which also * renews the session and says when it ends. */ public function get_connection(): WP_REST_Response { if ( $this->connection->is_usable() ) { $this->connection->refresh_status(); } return new WP_REST_Response( $this->state() ); } /** * Disconnects. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_disconnect( WP_REST_Request $request ) { $keep = (bool) $request->get_param( 'keepChat' ); $loaded = $request->get_header( self::CONNECTION_HEADER ); $refused = $this->connection->disconnect( $keep, null === $loaded ? null : (string) $loaded ); if ( null !== $refused ) { return self::respond( $refused ); } if ( $keep ) { // Chat stays with the Site ID: say whether it's on the home page. Installation_Check::run(); } return new WP_REST_Response( $this->state() ); } // --------------------------------------------------------------------- // Overview, who answers, go live. // --------------------------------------------------------------------- /** * The Overview. * * @return WP_REST_Response|WP_Error */ public function get_overview() { $status = Status::compute( $this->connection, $this->stand ); if ( 'nobody' === $status['state'] ) { if ( ! State::get( 'nobody_since' ) ) { State::set( 'nobody_since', time() ); } } else { State::forget( 'nobody_since' ); } $out = array( 'status' => $status, 'site' => null, 'who' => null, 'responders' => array(), 'week' => null, 'plan' => null, 'knowledge' => null, 'errors' => array(), ); if ( ! $this->connection->is_usable() ) { return new WP_REST_Response( $out ); } $parts = array( 'site' => function () { return $this->stand->site(); }, 'who' => function () { return $this->stand->who_answers(); }, 'responders' => function () { return $this->responders(); }, 'week' => function () { return $this->stand->week(); }, 'plan' => function () { return $this->stand->plan(); }, 'knowledge' => function () { $pages = $this->knowledge->pages(); return array( 'pages' => count( $pages['pages'] ), 'changed' => array_values( array_map( static function ( $page ) { return array( 'id' => $page['id'], 'title' => $page['title'], 'modified' => $page['modified'], ); }, array_filter( $pages['pages'], static function ( $page ) { return ! empty( $page['changed'] ); } ) ) ), ); }, ); // Once Stand can't be reached, the rest isn't asked of it: each would // wait for its timeout. $offline = empty( $status['stale'] ) ? null : new WP_Error( 'stand_unreachable', __( 'Stand is temporarily unreachable. The chat on your site is not affected.', 'stand-chat' ) ); foreach ( $parts as $key => $producer ) { $value = $offline ?? $producer(); if ( is_wp_error( $value ) ) { if ( Api_Client::is_temporary( $value ) ) { $offline = $value; } $out['errors'][ $key ] = array( 'code' => $value->get_error_code(), 'message' => $value->get_error_message(), ); continue; } $out[ $key ] = $value; } return new WP_REST_Response( $out ); } /** * Who would answer a new visitor right now, in words. * * @return array>|WP_Error */ private function responders() { $matches = $this->stand->matches(); if ( is_wp_error( $matches ) ) { return $matches; } // Stand returns every teammate who could answer; only the connected // person is "you". $me = (string) ( Connection::stored_metadata()['user']['userId'] ?? '' ); $out = array(); foreach ( $matches as $match ) { $person = 'rep' === ( $match['responderType'] ?? '' ); $out[] = array( 'type' => $person ? 'person' : 'ai', 'mine' => '' !== $me && (string) ( $person ? ( $match['repId'] ?? '' ) : ( $match['ownerRepId'] ?? '' ) ) === $me, 'name' => (string) ( $match['name'] ?? '' ), 'brandName' => (string) ( $match['brandName'] ?? '' ), 'avatar' => (string) ( $match['avatar'] ?? '' ), 'ownerName' => (string) ( $match['ownerName'] ?? '' ), 'pathPrefix' => (string) ( $match['pathPrefix'] ?? '' ), 'standinId' => (string) ( $match['standinProfileId'] ?? '' ), ); } return $out; } /** * The organization's plan and usage, such as its prompt length limit. * * @return WP_REST_Response|WP_Error */ public function get_plan() { return self::respond( $this->stand->plan() ); } /** * Sets Who answers new chats. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_who_answers( WP_REST_Request $request ) { return self::respond( $this->stand->set_who_answers( (string) $request->get_param( 'mode' ), (string) State::get( 'standin_id', '' ), (bool) $request->get_param( 'coverage' ) ) ); } /** * Go live: turns on the AI assistant, applies Who answers, starts printing * the chat and checks that it reaches the site. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_go_live( WP_REST_Request $request ) { $mode = (string) $request->get_param( 'mode' ); $standin = (string) State::get( 'standin_id', '' ); if ( '' === $standin && 'only_you' !== $mode ) { return new WP_Error( 'stand_chat_no_standin', __( 'Brief your AI assistant first.', 'stand-chat' ), array( 'status' => 400 ) ); } $identity = $this->connection->identity(); $who = $this->stand->set_who_answers( $mode, $standin, (bool) $request->get_param( 'coverage' ) ); if ( is_wp_error( $who ) ) { return self::respond( $who ); } // Live for the connection the request began with, not one made meanwhile. $live = $this->connection->as_connected( $identity, static function (): bool { Settings::update( array( 'chat_enabled' => true ) ); State::mark_setup( 'live' ); return true; } ); if ( is_wp_error( $live ) ) { return self::respond( $live ); } $check = Installation_Check::run(); Cache::flush(); return new WP_REST_Response( array( 'who' => $who, 'install' => $check, 'state' => $this->state(), ) ); } // --------------------------------------------------------------------- // Stand-ins. // --------------------------------------------------------------------- /** * The AI assistants for this site. * * @return WP_REST_Response|WP_Error */ public function get_standins() { $standins = $this->stand->standins(); if ( is_wp_error( $standins ) ) { return self::respond( $standins ); } $own = array(); $own_id = array(); foreach ( $standins as $standin ) { $own[] = $standin; $own_id[ (string) ( $standin['standinProfileId'] ?? '' ) ] = true; } $others = array(); $responders = $this->responders(); if ( ! is_wp_error( $responders ) ) { foreach ( $responders as $responder ) { if ( 'ai' === $responder['type'] && ! isset( $own_id[ $responder['standinId'] ] ) ) { $others[] = $responder; } } } $who = $this->stand->who_answers(); return new WP_REST_Response( array( 'standins' => $own, 'others' => $others, 'primaryId' => (string) State::get( 'standin_id', '' ), 'who' => is_wp_error( $who ) ? null : $who, 'siteId' => $this->connection->site_id(), ) ); } /** * Creates an AI assistant from the brief. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_standin( WP_REST_Request $request ) { $body = (array) $request->get_json_params(); $input = self::standin_fields( $body ); $errors = self::validate_standin( $input ); if ( is_wp_error( $errors ) ) { return $errors; } // What's recorded here belongs to the connection the request began with. $identity = $this->connection->identity(); $created = $this->stand->create_standin( $input ); if ( is_wp_error( $created ) ) { return self::respond( $created ); } $id = (string) $created['standinProfileId']; $primary = ! empty( $body['primary'] ); $recorded = $this->connection->as_connected( $identity, static function () use ( $id, $primary ): bool { State::change( 'standin_id', static function ( $current ) use ( $id, $primary ): string { return $primary || '' === (string) $current ? $id : (string) $current; } ); return true; } ); if ( is_wp_error( $recorded ) ) { return self::respond( $recorded ); } $warnings = array(); $email = sanitize_email( (string) ( $body['contactEmail'] ?? '' ) ); $skill = $this->stand->update_skill( $id, 'followup', true, '' !== $email ? array( 'recipientEmail' => $email ) : array() ); if ( is_wp_error( $skill ) ) { $warnings[] = $skill->get_error_message(); } $attached = $this->stand->attach_site_knowledge( $id ); if ( is_wp_error( $attached ) ) { $warnings[] = $attached->get_error_message(); } $look = $this->apply_look( $id, is_array( $body['look'] ?? null ) ? $body['look'] : array() ); if ( is_wp_error( $look ) ) { $warnings[] = $look->get_error_message(); } $briefed = $this->connection->as_connected( $identity, static function (): bool { State::mark_setup( 'brief' ); return true; } ); if ( is_wp_error( $briefed ) ) { return self::respond( $briefed ); } $fresh = $this->stand->standin( $id ); return new WP_REST_Response( array( 'standin' => is_wp_error( $fresh ) ? $created : $fresh, 'warnings' => $warnings, ) ); } /** * One AI assistant with its skills and knowledge. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_standin( WP_REST_Request $request ) { $id = (string) $request['id']; $standin = $this->stand->standin( $id ); if ( is_wp_error( $standin ) ) { return self::respond( $standin ); } $skills = $this->stand->skills( $id ); $knowledge = $this->stand->standin_knowledge( $id ); return new WP_REST_Response( array( 'standin' => $standin, 'skills' => is_wp_error( $skills ) ? array() : $skills, 'knowledge' => is_wp_error( $knowledge ) ? null : $knowledge, 'siteKnowledgeBaseId' => (string) State::get( 'knowledge_base_id', '' ), ) ); } /** * Saves an AI assistant's Identity. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_standin( WP_REST_Request $request ) { $current = $this->stand->standin( (string) $request['id'] ); if ( is_wp_error( $current ) ) { return self::respond( $current ); } // The form's edits on top of the Stand-in as it is now. Only this Site's // own AI assistants change from WordPress (Stand::update_standin). $input = self::standin_fields( array_merge( $current, (array) $request->get_json_params() ) ); $input['siteId'] = $this->connection->site_id(); $errors = self::validate_standin( $input ); if ( is_wp_error( $errors ) ) { return $errors; } if ( isset( $current['toolsEnabled'] ) ) { $input['toolsEnabled'] = (string) $current['toolsEnabled']; } // The revision the form was loaded with: the save is turned away when // the Stand-in changed in Stand since, instead of losing those changes. $revision = (string) $request->get_param( 'expectedRevision' ); if ( '' !== $revision && (string) ( $current['revision'] ?? '' ) !== $revision ) { return self::respond( self::standin_changed() ); } return self::respond( $this->stand->update_standin( (string) $request['id'], $input ) ); } /** * The Stand-in changed in Stand after the form was loaded. */ private static function standin_changed(): WP_Error { return new WP_Error( 'stand_conflict', __( 'This AI assistant changed in Stand meanwhile. Reload to see the changes, then make your edits again.', 'stand-chat' ), array( 'status' => 409 ) ); } /** * Turns an AI assistant On or Off. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_standin_enabled( WP_REST_Request $request ) { return self::respond( $this->stand->switch_standin( (string) $request['id'], (bool) $request->get_param( 'enabled' ) ) ); } /** * Updates a skill. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_skill( WP_REST_Request $request ) { $key = (string) $request['key']; if ( 'followup' !== $key ) { return new WP_Error( 'stand_chat_skill', __( 'Set up this skill under Stand-ins in Stand.', 'stand-chat' ), array( 'status' => 400 ) ); } $config = array(); $raw = (array) $request->get_param( 'config' ); if ( array_key_exists( 'guidance', $raw ) ) { $config['guidance'] = mb_substr( sanitize_textarea_field( (string) $raw['guidance'] ), 0, 2000 ); } if ( array_key_exists( 'recipientEmail', $raw ) ) { $email = trim( (string) $raw['recipientEmail'] ); if ( '' !== $email && ! is_email( $email ) ) { return new WP_Error( 'stand_chat_email', __( 'Enter a valid email address, or leave it empty to use your account email.', 'stand-chat' ), array( 'status' => 400 ) ); } $config['recipientEmail'] = $email; } return self::respond( $this->stand->update_skill( (string) $request['id'], $key, (bool) $request->get_param( 'enabled' ), $config ) ); } /** * Changes an AI assistant's look. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_look( WP_REST_Request $request ) { $result = $this->apply_look( (string) $request['id'], (array) $request->get_json_params() ); if ( is_wp_error( $result ) ) { return self::respond( $result ); } return self::respond( $this->stand->standin( (string) $request['id'] ) ); } /** * Generated looks to choose from. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_looks( WP_REST_Request $request ) { $id = sanitize_text_field( (string) $request->get_param( 'standinId' ) ); if ( '' !== $id ) { return self::respond( $this->stand->looks( $id ) ); } return self::respond( $this->stand->preview_looks( array( 'displayName' => sanitize_text_field( (string) $request->get_param( 'displayName' ) ), 'greeting' => sanitize_text_field( (string) $request->get_param( 'greeting' ) ), 'prompt' => sanitize_textarea_field( (string) $request->get_param( 'prompt' ) ), ) ) ); } /** * Attaches the site's knowledge base to an AI assistant. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_attach_knowledge( WP_REST_Request $request ) { return self::respond( $this->stand->attach_site_knowledge( (string) $request['id'] ) ); } /** * Applies a look: site_icon, upload, candidate or remove. * * @param string $id Stand-in ID. * @param array $look Look choice. * @return true|WP_Error */ private function apply_look( string $id, array $look ) { $type = (string) ( $look['type'] ?? '' ); switch ( $type ) { case 'site_icon': $icon = (int) get_option( 'site_icon' ); if ( ! $icon ) { return new WP_Error( 'stand_chat_no_icon', __( 'This site has no Site Icon. Add one in Settings → General, or generate a look.', 'stand-chat' ), array( 'status' => 400 ) ); } $result = $this->stand->upload_look( $id, $icon ); break; case 'upload': $attachment = (int) ( $look['attachmentId'] ?? 0 ); if ( ! $attachment || ! current_user_can( 'edit_post', $attachment ) ) { return new WP_Error( 'stand_chat_upload', __( 'Choose an image from your Media Library.', 'stand-chat' ), array( 'status' => 400 ) ); } $result = $this->stand->upload_look( $id, $attachment ); break; case 'candidate': $result = $this->stand->select_look( $id, sanitize_text_field( (string) ( $look['candidateId'] ?? '' ) ) ); break; case 'remove': $result = $this->stand->remove_look( $id ); break; default: return true; } return is_wp_error( $result ) ? $result : true; } /** * Stand-in fields from a request. * * @param array $body Request body. * @return array */ private static function standin_fields( array $body ): array { $display = trim( sanitize_text_field( (string) ( $body['displayName'] ?? '' ) ) ); $name = trim( sanitize_text_field( (string) ( $body['name'] ?? '' ) ) ); $path = trim( (string) ( $body['pathPrefix'] ?? '' ) ); return array( 'name' => '' !== $name ? $name : $display, 'displayName' => $display, 'greeting' => trim( sanitize_text_field( (string) ( $body['greeting'] ?? '' ) ) ), 'prompt' => trim( sanitize_textarea_field( (string) ( $body['prompt'] ?? '' ) ) ), 'displayBrandName' => trim( sanitize_text_field( (string) ( $body['displayBrandName'] ?? '' ) ) ), 'pathPrefix' => '' === $path ? '' : '/' . ltrim( $path, '/' ), ); } /** * Checks required Stand-in fields. * * @param array $input Fields. * @return true|WP_Error */ private static function validate_standin( array $input ) { if ( '' === $input['displayName'] ) { return new WP_Error( 'stand_chat_name', __( 'Give your AI assistant a name.', 'stand-chat' ), array( 'status' => 400 ) ); } if ( '' === $input['prompt'] ) { return new WP_Error( 'stand_chat_prompt', __( 'Say in one line what your business does, so the Stand-in prompt has something to work from.', 'stand-chat' ), array( 'status' => 400 ) ); } if ( '' !== $input['pathPrefix'] && ( strlen( $input['pathPrefix'] ) > 255 || ( false !== strpos( $input['pathPrefix'], '*' ) && '/*' !== substr( $input['pathPrefix'], -2 ) ) ) ) { return new WP_Error( 'stand_chat_path', __( 'Choose the pages it answers on from the list.', 'stand-chat' ), array( 'status' => 400 ) ); } return true; } // --------------------------------------------------------------------- // Knowledge. // --------------------------------------------------------------------- /** * Knowledge: plan limits, picked pages and their status. * * @return WP_REST_Response|WP_Error */ public function get_knowledge() { return self::respond( $this->knowledge_payload() ); } /** * The knowledge payload. * * @return array|WP_Error */ private function knowledge_payload() { $overview = $this->stand->knowledge_overview(); if ( is_wp_error( $overview ) ) { return $overview; } $kb_id = (string) State::get( 'knowledge_base_id', '' ); $kb = null; foreach ( (array) ( $overview['knowledgeBases'] ?? array() ) as $candidate ) { if ( ( $candidate['knowledgeBaseId'] ?? '' ) === $kb_id ) { $kb = $candidate; } } $pages = $this->knowledge->pages(); return array( 'plan' => array( 'planCode' => (string) ( $overview['planCode'] ?? '' ), 'planName' => (string) ( $overview['planName'] ?? '' ), 'pageLimit' => $overview['capabilities']['pageCountLimit'] ?? null, 'sitemapAllowed' => ! empty( $overview['capabilities']['sitemapAllowed'] ), 'refreshAllowed' => ! empty( $overview['capabilities']['automaticRefreshAllowed'] ), 'documentsAllowed' => ! empty( $overview['capabilities']['documentUploadAllowed'] ), 'ingestionAllowed' => ! empty( $overview['ingestionAllowed'] ), 'graceReadOnly' => ! empty( $overview['graceReadOnly'] ), 'unavailableReason' => (string) ( $overview['ingestionUnavailableReason'] ?? '' ), 'usage' => $overview['usage'] ?? array(), ), 'knowledgeBase' => $kb, 'pages' => $pages['pages'], 'others' => $pages['others'], 'sitemap' => array( 'url' => Knowledge::sitemap_url(), 'source' => $pages['sitemap'], ), 'stale' => $pages['stale'], 'readOnPublish' => (bool) Settings::get( 'read_on_publish' ), ); } /** * Picks and unpicks pages. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_knowledge_pages( WP_REST_Request $request ) { $errors = $this->knowledge->remove( array_map( 'absint', (array) $request->get_param( 'remove' ) ) ); $errors += $this->knowledge->add( array_map( 'absint', (array) $request->get_param( 'add' ) ) ); return $this->knowledge_response( $errors ); } /** * Reads pages again. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_read_again( WP_REST_Request $request ) { $ids = array_map( 'absint', (array) $request->get_param( 'ids' ) ); if ( $request->get_param( 'changed' ) ) { $ids = array_merge( $ids, array_map( static function ( $page ) { return (int) $page['id']; }, $this->knowledge->changed() ) ); } return $this->knowledge_response( $this->knowledge->read_again( array_unique( $ids ) ) ); } /** * Knowledge payload with per-page errors. * * @param array $errors Errors by post ID. * @return WP_REST_Response|WP_Error */ private function knowledge_response( array $errors ) { $payload = $this->knowledge_payload(); if ( is_wp_error( $payload ) ) { return self::respond( $payload ); } $payload['errors'] = array(); foreach ( $errors as $id => $message ) { $payload['errors'][] = array( 'id' => (int) $id, 'title' => html_entity_decode( get_the_title( (int) $id ), ENT_QUOTES ), 'message' => $message, ); } return new WP_REST_Response( $payload ); } /** * Cancels reading. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_cancel_task( WP_REST_Request $request ) { $result = $this->stand->cancel_task( (string) $request['task'] ); return is_wp_error( $result ) ? self::respond( $result ) : $this->knowledge_response( array() ); } /** * What does the AI assistant find? * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_search( WP_REST_Request $request ) { $query = trim( sanitize_text_field( (string) $request->get_param( 'query' ) ) ); if ( '' === $query ) { return new WP_REST_Response( array( 'results' => array() ) ); } $results = $this->stand->search( mb_substr( $query, 0, 300 ) ); if ( is_wp_error( $results ) ) { return self::respond( $results ); } $out = array(); foreach ( $results as $result ) { $url = (string) ( $result['sourceUrl'] ?? '' ); $post_id = '' !== $url ? url_to_postid( $url ) : 0; $out[] = array( 'title' => $post_id ? html_entity_decode( get_the_title( $post_id ), ENT_QUOTES ) : (string) ( $result['documentTitle'] ?? $result['sourceName'] ?? '' ), 'path' => '' !== $url ? wp_make_link_relative( $url ) : '', 'excerpt' => wp_trim_words( (string) ( $result['chunkText'] ?? '' ), 30, '…' ), 'score' => (float) ( $result['score'] ?? 0 ), ); } return new WP_REST_Response( array( 'results' => $out ) ); } /** * Reads the WordPress sitemap ([Pro]). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_sitemap( WP_REST_Request $request ) { $result = $this->stand->add_sitemap( Knowledge::sitemap_url(), (bool) $request->get_param( 'nightly' ) ); return is_wp_error( $result ) ? self::respond( $result ) : $this->knowledge_response( array() ); } /** * Nightly sitemap updates ([Business]). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_sitemap_refresh( WP_REST_Request $request ) { $result = $this->stand->set_sitemap_refresh( sanitize_text_field( (string) $request->get_param( 'sourceId' ) ), (bool) $request->get_param( 'enabled' ) ); return is_wp_error( $result ) ? self::respond( $result ) : $this->knowledge_response( array() ); } /** * WordPress content for the pickers. * * @param WP_REST_Request $request Request. */ public function get_content( WP_REST_Request $request ): WP_REST_Response { $type = sanitize_key( (string) ( $request->get_param( 'type' ) ?? 'page' ) ); $search = sanitize_text_field( (string) $request->get_param( 'search' ) ); $page = max( 1, (int) $request->get_param( 'page' ) ); $types = Knowledge::post_types(); $selected = array_flip( Knowledge::selected_ids() ); $reasons = Knowledge::suggestions(); $privacy = (int) get_option( 'wp_page_for_privacy_policy' ); $query = new \WP_Query( array( 'post_type' => in_array( $type, $types, true ) ? $type : 'page', 'post_status' => 'publish', 's' => $search, 'posts_per_page' => 50, 'paged' => $page, 'orderby' => 'menu_order title', 'order' => 'ASC', 'no_found_rows' => false, ) ); // Which listed pages have subpages, in one query rather than one each. $pages = array(); foreach ( $query->posts as $post ) { if ( $post instanceof WP_Post && 'page' === $post->post_type ) { $pages[] = $post->ID; } } $parents = empty( $pages ) ? array() : array_flip( get_posts( array( 'post_type' => 'page', 'post_status' => 'any', 'post_parent__in' => $pages, 'fields' => 'id=>parent', 'posts_per_page' => -1, 'orderby' => 'none', ) ) ); $items = array(); foreach ( $query->posts as $post ) { if ( ! $post instanceof WP_Post ) { continue; } $reason = $reasons[ $post->ID ] ?? ''; if ( $post->ID === $privacy ) { $reason = 'not_recommended'; } if ( '' !== $post->post_password ) { $reason = 'protected'; } $items[] = array( 'id' => $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES ), 'path' => wp_make_link_relative( (string) get_permalink( $post ) ), 'pattern' => Behavior_Rules::rule_path_for_post( $post->ID ), 'url' => (string) get_permalink( $post ), 'type' => $post->post_type, 'parent' => (int) $post->post_parent, 'modified' => gmdate( 'c', (int) strtotime( $post->post_modified_gmt . ' UTC' ) ), 'reason' => $reason, 'selected' => isset( $selected[ $post->ID ] ), 'hasChildren' => 'page' === $post->post_type && isset( $parents[ $post->ID ] ), ); } if ( 1 === $page && '' === $search ) { // Suggested pages first: the front page, menu pages and common pages. $order = array( 'front' => 0, 'menu' => 1, 'common' => 2, '' => 3, 'not_recommended' => 4, 'protected' => 5, ); usort( $items, static function ( $a, $b ) use ( $order ) { return ( $order[ $a['reason'] ] ?? 3 ) <=> ( $order[ $b['reason'] ] ?? 3 ); } ); } $types_out = array(); foreach ( $types as $post_type ) { $object = get_post_type_object( $post_type ); if ( $object ) { $types_out[] = array( 'name' => $post_type, 'label' => (string) $object->labels->name, ); } } return new WP_REST_Response( array( 'items' => $items, 'total' => (int) $query->found_posts, 'pages' => (int) $query->max_num_pages, 'types' => $types_out, ) ); } // --------------------------------------------------------------------- // Chat widget behavior. // --------------------------------------------------------------------- /** * Behavior rules with WordPress labels. * * @return WP_REST_Response|WP_Error */ public function get_behavior() { $site = $this->stand->site( true ); if ( is_wp_error( $site ) ) { return self::respond( $site ); } return new WP_REST_Response( self::behavior_payload( $site, $this->connection->is_owner() ) ); } /** * Page addresses that changed while a Behavior rule still uses the old one. * * @param array> $rules Rules. * @return array> */ private static function path_changes( array $rules ): array { $changes = State::get( 'path_changes', array() ); $out = array(); foreach ( is_array( $changes ) ? $changes : array() as $change ) { $old = untrailingslashit( (string) ( $change['oldPath'] ?? '' ) ); if ( '' === $old || untrailingslashit( (string) $change['newPath'] ) === $old ) { continue; } foreach ( $rules as $rule ) { $pattern = (string) ( $rule['pathPrefix'] ?? '' ); $base = untrailingslashit( '/*' === substr( $pattern, -2 ) ? substr( $pattern, 0, -2 ) : $pattern ); if ( '' !== $base && $base === $old ) { $change['title'] = html_entity_decode( get_the_title( (int) $change['postId'] ), ENT_QUOTES ); $change['canUpdate'] = null !== Behavior_Rules::rule_path_for_post( (int) $change['postId'] ); $out[] = $change; break; } } } return $out; } /** * Saves Behavior rules (owner only). Fails with a conflict when the rules * changed in Stand since they were loaded. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_behavior( WP_REST_Request $request ) { $resolved = array_map( 'absint', (array) $request->get_param( 'resolvedPathChanges' ) ); foreach ( $resolved as $post_id ) { if ( null === Behavior_Rules::rule_path_for_post( $post_id ) ) { return new WP_Error( 'stand_chat_page_path', Behavior_Rules::page_rule_unavailable(), array( 'status' => 400 ) ); } } $site = $this->stand->site( true ); if ( is_wp_error( $site ) ) { return self::respond( $site ); } $config = (array) ( $site['behaviorConfig'] ?? array() ); $current = array_values( (array) ( $config['rules'] ?? array() ) ); $revision = (string) $request->get_param( 'revision' ); if ( (string) ( $site['revision'] ?? '' ) !== $revision ) { return self::rules_changed(); } $rules = Behavior_Rules::prepare( (array) $request->get_param( 'rules' ), $current ); if ( is_wp_error( $rules ) ) { return self::respond( $rules ); } $saved = $this->save_rules( $config, $rules ); if ( is_wp_error( $saved ) ) { return self::respond( $saved ); } if ( ! empty( $resolved ) ) { State::change( 'path_changes', static function ( $changes ) use ( $resolved ): array { $changes = is_array( $changes ) ? $changes : array(); foreach ( $resolved as $post_id ) { unset( $changes[ $post_id ] ); } return $changes; } ); } return new WP_REST_Response( self::behavior_payload( $saved, $this->connection->is_owner() ) ); } /** * Writes the rules, keeping the other behavior settings as they are. The * caller compared the revision with a fresh read just before. * * @param array $config Current behaviorConfig. * @param array> $rules New rules. * @return array|WP_Error The saved Site. */ private function save_rules( array $config, array $rules ) { return $this->stand->update_site( array( 'behaviorConfig' => array( 'advancedBehaviorEnabled' => ! empty( $config['advancedBehaviorEnabled'] ), 'allowStandAdminBehavior' => ! empty( $config['allowStandAdminBehavior'] ), 'rules' => $rules, ), ) ); } /** * The rules changed in Stand after they were loaded here. */ private static function rules_changed(): WP_Error { return new WP_Error( 'stand_conflict', __( 'The Behavior rules changed in Stand meanwhile. Reload to see them, then make your change again.', 'stand-chat' ), array( 'status' => 409 ) ); } /** * Behavior payload. * * @param array $site Site. * @param bool $is_owner Whether the connected user is the owner. * @return array */ private static function behavior_payload( array $site, bool $is_owner ): array { $rules = array_values( (array) ( $site['behaviorConfig']['rules'] ?? array() ) ); $out = array(); foreach ( $rules as $rule ) { $rule['customJavascriptSet'] = Behavior_Rules::scripted( $rule ); unset( $rule['customJavascript'] ); $rule['where'] = Behavior_Rules::describe( (string) ( $rule['pathPrefix'] ?? '' ) ); $out[] = $rule; } return array( 'rules' => $out, 'revision' => (string) ( $site['revision'] ?? '' ), 'limit' => Behavior_Rules::LIMIT, 'isOwner' => $is_owner, 'frontPath' => Behavior_Rules::front_path(), 'pathChanges' => self::path_changes( $rules ), ); } /** * The editor panel: this page's chat behavior and knowledge status. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_page( WP_REST_Request $request ) { $post = get_post( (int) $request['post'] ); if ( ! $post instanceof WP_Post ) { return new WP_Error( 'stand_chat_post', __( 'Page not found.', 'stand-chat' ), array( 'status' => 404 ) ); } $rule_path = Behavior_Rules::rule_path_for_post( $post->ID ); $unavailable = ''; if ( null === $rule_path ) { // WordPress gives unpublished pages a ?page_id= address until they're // published, whatever the permalink structure. $unavailable = 'publish' !== $post->post_status && '' !== (string) get_option( 'permalink_structure' ) ? __( 'Publish the page to give it its own chat behavior.', 'stand-chat' ) : Behavior_Rules::page_rule_unavailable(); } $out = array( 'connected' => $this->connection->is_usable(), 'canManage' => $this->can_manage(), 'isOwner' => $this->connection->is_owner(), 'path' => Behavior_Rules::path_for_post( $post->ID ), 'published' => 'publish' === $post->post_status, 'behavior' => null, 'behaviorUnavailable' => $unavailable, 'knowledge' => null, 'responder' => null, ); if ( ! $out['connected'] ) { return new WP_REST_Response( $out ); } $site = $this->stand->site(); if ( ! is_wp_error( $site ) && null !== $rule_path ) { $rules = array_values( (array) ( $site['behaviorConfig']['rules'] ?? array() ) ); $own = null; $own_at = null; foreach ( $rules as $index => $rule ) { // With two rules for the page, the first one is its rule. if ( null === $own_at && '/*' !== substr( (string) ( $rule['pathPrefix'] ?? '' ), -2 ) && untrailingslashit( (string) ( $rule['pathPrefix'] ?? '' ) ) === untrailingslashit( $out['path'] ) && '' !== (string) $rule['pathPrefix'] ) { $own = $rule; $own_at = $index; } } // What applies without the page's own rule: Default behavior. $others = $rules; if ( null !== $own_at ) { array_splice( $others, $own_at, 1 ); } $applied = Behavior_Rules::first_match( $others, $out['path'] ); $mode = 'default'; if ( $own ) { $mode = 'none' === ( $own['trigger']['type'] ?? '' ) ? 'hidden' : 'custom'; unset( $own['customJavascript'] ); } $out['behavior'] = array( 'mode' => $mode, 'rule' => $own, 'applied' => null === $applied ? null : array( 'name' => (string) ( $others[ $applied ]['name'] ?? '' ), 'where' => Behavior_Rules::describe( (string) ( $others[ $applied ]['pathPrefix'] ?? '' ) ), ), 'count' => count( $rules ), 'limit' => Behavior_Rules::LIMIT, 'revision' => (string) ( $site['revision'] ?? '' ), // The Site's rules have custom JavaScript: they change only in Stand. 'scripted' => Behavior_Rules::has_custom_javascript( $rules ), ); } $source = (string) get_post_meta( $post->ID, Knowledge::META_SOURCE, true ); $row = null; if ( '' !== $source ) { foreach ( $this->knowledge->pages()['pages'] as $page ) { if ( (int) $page['id'] === $post->ID ) { $row = $page; } } } $out['knowledge'] = array( 'selected' => null !== $row, 'page' => $row, 'readOnPublish' => (bool) Settings::get( 'read_on_publish' ), 'readable' => true === $this->knowledge->readable( $post ) || 'publish' !== $post->post_status, ); $standins = $this->stand->standins(); if ( ! is_wp_error( $standins ) ) { $out['standins'] = array_map( static function ( $standin ) { return array( 'id' => (string) $standin['standinProfileId'], 'displayName' => (string) $standin['displayName'], 'enabled' => ! empty( $standin['enabled'] ), 'siteId' => $standin['siteId'] ?? null, 'pathPrefix' => (string) ( $standin['pathPrefix'] ?? '' ), ); }, $standins ); } return new WP_REST_Response( $out ); } /** * Saves the editor panel's choices for one page. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_page( WP_REST_Request $request ) { $post = get_post( (int) $request['post'] ); if ( ! $post instanceof WP_Post ) { return new WP_Error( 'stand_chat_post', __( 'Page not found.', 'stand-chat' ), array( 'status' => 404 ) ); } $behavior = $request->get_param( 'behavior' ); $rule_path = Behavior_Rules::rule_path_for_post( $post->ID ); if ( is_array( $behavior ) && null === $rule_path ) { return new WP_Error( 'stand_chat_page_path', Behavior_Rules::page_rule_unavailable(), array( 'status' => 400 ) ); } $knowledge = $request->get_param( 'knowledge' ); if ( is_array( $knowledge ) ) { if ( isset( $knowledge['readOnPublish'] ) ) { Settings::update( array( 'read_on_publish' => (bool) $knowledge['readOnPublish'] ) ); } $action = (string) ( $knowledge['action'] ?? '' ); if ( 'add' === $action ) { $errors = $this->knowledge->add( array( $post->ID ) ); } elseif ( 'remove' === $action ) { $errors = $this->knowledge->remove( array( $post->ID ) ); } elseif ( 'read_again' === $action ) { $errors = $this->knowledge->read_again( array( $post->ID ) ); } else { $errors = array(); } if ( ! empty( $errors ) ) { return new WP_Error( 'stand_chat_knowledge', (string) reset( $errors ), array( 'status' => 400 ) ); } } if ( is_array( $behavior ) ) { $site = $this->stand->site( true ); if ( is_wp_error( $site ) ) { return self::respond( $site ); } $config = (array) ( $site['behaviorConfig'] ?? array() ); $current = array_values( (array) ( $config['rules'] ?? array() ) ); $revision = (string) ( $behavior['revision'] ?? '' ); if ( (string) ( $site['revision'] ?? '' ) !== $revision ) { return self::rules_changed(); } $path = $rule_path; $title = html_entity_decode( get_the_title( $post ), ENT_QUOTES ); $mode = (string) ( $behavior['mode'] ?? 'default' ); if ( 'hidden' === $mode ) { /* translators: %s: page title */ $rule = Behavior_Rules::hidden_rule( sprintf( __( '%s: chat widget hidden', 'stand-chat' ), $title ) ); } elseif ( 'custom' === $mode && is_array( $behavior['rule'] ?? null ) ) { $rule = $behavior['rule']; $rule['name'] = '' !== trim( (string) ( $rule['name'] ?? '' ) ) ? $rule['name'] : $title; } else { $rule = null; } $rules = Behavior_Rules::upsert_page_rule( $current, $path, $rule ); $rules = Behavior_Rules::prepare( $rules, $current ); if ( is_wp_error( $rules ) ) { return self::respond( $rules ); } $saved = $this->save_rules( $config, $rules ); if ( is_wp_error( $saved ) ) { return self::respond( $saved ); } } return $this->get_page( $request ); } // --------------------------------------------------------------------- // Installation, Site, prompts, team, address, notices, support. // --------------------------------------------------------------------- /** * Checks the site for the chat script now. */ public function post_check(): WP_REST_Response { $check = Installation_Check::run(); if ( $this->connection->is_usable() ) { // Stand marks the Site installed when the chat first loads on it. Cache::forget( $this->connection->site_id() . ':site' ); } return new WP_REST_Response( array( 'install' => $check, 'message' => Status::check_message( $check ), ) ); } /** * The Site in Stand. * * @return WP_REST_Response|WP_Error */ public function get_site() { return self::respond( $this->stand->site( true ) ); } /** * Fallback language and Turn off this Site in Stand (owner only). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_site( WP_REST_Request $request ) { $changes = array(); if ( $request->has_param( 'defaultLanguageCode' ) ) { $code = sanitize_key( (string) $request->get_param( 'defaultLanguageCode' ) ); $changes['defaultLanguageCode'] = '' === $code ? null : $code; } if ( null !== $request->get_param( 'enabled' ) ) { $changes['enabled'] = (bool) $request->get_param( 'enabled' ); } if ( empty( $changes ) ) { return new WP_Error( 'stand_chat_nothing', __( 'Nothing to change.', 'stand-chat' ), array( 'status' => 400 ) ); } return self::respond( $this->stand->update_site( $changes ) ); } /** * The Site AI prompt, the Team AI prompt and what WordPress knows. * * @return WP_REST_Response|WP_Error */ public function get_site_prompt() { $prompt = $this->stand->site_prompt(); if ( is_wp_error( $prompt ) ) { return self::respond( $prompt ); } $team = $this->stand->team_prompt(); $plan = $this->stand->plan(); return new WP_REST_Response( array( 'prompt' => $prompt['prompt'] ?? null, 'team' => is_wp_error( $team ) ? null : $team, 'limit' => is_wp_error( $plan ) ? 4000 : (int) ( $plan['plan']['promptCharacterLimit'] ?? 4000 ), 'isOwner' => $this->connection->is_owner(), 'prefill' => Prompt_Prefill::site(), ) ); } /** * Saves the Site AI prompt. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function put_site_prompt( WP_REST_Request $request ) { $content = trim( sanitize_textarea_field( (string) $request->get_param( 'content' ) ) ); if ( '' === $content ) { return self::respond( $this->stand->delete_site_prompt() ); } return self::respond( $this->stand->save_site_prompt( $content ) ); } /** * Clears the Site AI prompt. * * @return WP_REST_Response|WP_Error */ public function delete_site_prompt() { return self::respond( $this->stand->delete_site_prompt() ); } /** * Team members and invitations. * * @return WP_REST_Response|WP_Error */ public function get_team() { return self::respond( $this->stand->team() ); } /** * "Your site address changed": a copy, or decide later. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function post_address( WP_REST_Request $request ) { $action = (string) $request->get_param( 'action' ); if ( 'copy' === $action ) { State::set( 'address_decision', 'copy' ); } elseif ( 'later' === $action ) { State::dismiss( 'address', time() + WEEK_IN_SECONDS ); State::dismiss( 'admin_address', time() + WEEK_IN_SECONDS ); } else { return new WP_Error( 'stand_chat_address', __( 'Choose what this address is.', 'stand-chat' ), array( 'status' => 400 ) ); } return new WP_REST_Response( $this->state() ); } /** * Dismisses a notice. * * @param WP_REST_Request $request Request. */ public function post_dismiss( WP_REST_Request $request ): WP_REST_Response { $notice = sanitize_key( (string) $request['notice'] ); $until = 0; if ( 'connection_ends' === $notice ) { // Until that connection ends: the next one warns again. $until = max( time() + DAY_IN_SECONDS, (int) strtotime( (string) ( Connection::stored_metadata()['expires_at'] ?? '' ) ) ); } State::dismiss( $notice, $until ); return new WP_REST_Response( array( 'notices' => Notices::for_plugin_pages( $this->connection ) ) ); } /** * Information for support. Never includes the token. */ public function get_support(): WP_REST_Response { return new WP_REST_Response( Support::info( $this->connection ) ); } // --------------------------------------------------------------------- // Helpers. // --------------------------------------------------------------------- /** * Turns a Stand result into a REST response, with an HTTP status the admin * screens can branch on. * * @param mixed $result Result or WP_Error. * @return WP_REST_Response|WP_Error */ public static function respond( $result ) { if ( ! is_wp_error( $result ) ) { return new WP_REST_Response( $result ); } $statuses = array( 'stand_reconnect_needed' => 409, 'stand_unreachable' => 503, 'stand_owner_only' => 403, 'stand_forbidden' => 403, 'stand_generalist' => 403, 'stand_insufficient_scope' => 403, 'stand_coverage_consent' => 409, 'stand_all_sites_standin' => 409, 'stand_some_pages_only' => 409, 'stand_teammate_standin' => 409, 'stand_onboarding' => 409, 'stand_site_choice' => 409, 'stand_account_disabled' => 403, 'stand_plan_limit' => 402, 'stand_rate_limited' => 429, 'stand_not_found' => 404, 'stand_conflict' => 409, 'stand_busy' => 409, 'stand_invalid' => 400, 'stand_script_rule' => 403, 'stand_unavailable' => 501, 'stand_connect_unavailable' => 501, 'stand_oauth' => 502, 'stand_server_error' => 502, ); $data = (array) $result->get_error_data(); $data['status'] = $statuses[ $result->get_error_code() ] ?? ( isset( $data['status'] ) && $data['status'] >= 400 ? (int) $data['status'] : 500 ); unset( $data['reason'] ); return new WP_Error( $result->get_error_code(), $result->get_error_message(), $data ); } /** * A cookie banner plugin that supports the WP Consent API, if active. */ private static function consent_banner(): string { $banners = array( 'Complianz' => defined( 'cmplz_version' ), 'CookieYes' => defined( 'CLI_VERSION' ) || defined( 'COOKIEYES_VERSION' ), 'Cookiebot' => defined( 'CYBOT_COOKIEBOT_VERSION' ), 'iubenda' => defined( 'IUBENDA_PLUGIN_VERSION' ) || class_exists( 'iubenda' ), 'Borlabs' => defined( 'BORLABS_COOKIE_VERSION' ), ); foreach ( $banners as $name => $active ) { if ( $active ) { return $name; } } return ''; } }