*/ private $claims = array(); /** * Who was connected, to which Site, when a request's changes began: each * step goes to that connection only. * * @var string|null */ private $pinned = null; /** * Constructor. * * @param Connection $connection Connection. */ public function __construct( Connection $connection ) { $this->connection = $connection; } /** * The connection Stand is asked through. */ public function connection(): Connection { return $this->connection; } /** * Sends an authenticated request. * * @param string $method HTTP method. * @param string $path API path. * @param array $args Request arguments. * @return array|WP_Error */ private function call( string $method, string $path, array $args = array() ) { if ( ! $this->connection->is_usable() ) { return new WP_Error( 'stand_reconnect_needed', Api_Client::reconnect_message(), array( 'status' => 401 ) ); } $identity = null === $this->pinned ? null : $this->connection->identity(); if ( null !== $identity && $identity !== $this->pinned ) { // Connected again meanwhile, maybe as another person: the rest of // the change would go to their account, such as turning on their // Stand-in coverage. return self::reconnected( $identity ); } foreach ( $this->claims as $what => $token ) { // Each step keeps the change's claim alive, however long the change // takes. It's lost only when one step took longer than a claim holds // and another request took it: then this change stops, as it would // when a step fails. if ( ! Option_Queue::renew( self::OPTION_CLAIMS, $what, $token ) ) { return self::busy(); } } return $this->connection->api()->request( $method, $path, $args ); } /** * Why a change stopped: WordPress was connected again meanwhile, maybe as * another person, or disconnected, so the rest isn't this connection's to * make. * * @param string $identity Connection::identity() now. */ public static function reconnected( string $identity ): WP_Error { if ( '|' === $identity ) { return new WP_Error( 'stand_conflict', __( 'Stand Chat was disconnected meanwhile, so this change stopped. Reload the page.', 'stand-chat' ) ); } return new WP_Error( 'stand_conflict', __( 'Stand Chat was connected again meanwhile, so this change stopped. Reload and try again.', 'stand-chat' ) ); } /** * Why a change isn't made: another request is making one like it. */ private static function busy(): WP_Error { return new WP_Error( 'stand_busy', __( 'Another change like this is being made in WordPress right now. Try again in a moment.', 'stand-chat' ) ); } /** * Path for the connected Site. * * @param string $suffix Path after /v1/sites/{siteId}. */ private function site_path( string $suffix = '' ): string { return '/v1/sites/' . rawurlencode( $this->connection->site_id() ) . $suffix; } /** * Cache key scoped to the connected Site. * * @param string $key Key. */ private function key( string $key ): string { return $this->connection->site_id() . ':' . $key; } // --------------------------------------------------------------------- // Site. // --------------------------------------------------------------------- /** * The connected Site, with the revision of its Behavior rules. * * @param bool $fresh Skip the cache. * @return array|WP_Error */ public function site( bool $fresh = false ) { if ( $fresh ) { Cache::forget( $this->key( 'site' ) ); } return Cache::remember( $this->key( 'site' ), 60, function () { $site = $this->call( 'GET', $this->site_path() ); if ( is_wp_error( $site ) ) { return $site; } $this->connection->remember_site( $site ); return self::with_revision( $site, array( 'behaviorConfig' ) ); } ); } /** * Changes Site settings (owner only): enabled, defaultLanguageCode and * behaviorConfig. Never sends the legacy embedOptions, which would replace * every Behavior rule. * * @param array $changes Changes. * @return array|WP_Error */ public function update_site( array $changes ) { $allowed = array_intersect_key( $changes, array_flip( array( 'enabled', 'defaultLanguageCode', 'behaviorConfig' ) ) ); // Named before asking: connected to another Site meanwhile, this Site's // answer still goes under its own name. $site = $this->key( 'site' ); $matches = $this->key( 'matches' ); $result = $this->call( 'PUT', $this->site_path(), array( 'body' => $allowed ) ); Cache::forget( $site ); Cache::forget( $matches ); if ( ! is_wp_error( $result ) ) { $this->connection->remember_site( $result ); $result = self::with_revision( $result, array( 'behaviorConfig' ) ); Cache::set( $site, $result, 60 ); } return $result; } /** * Adds the plugin's revision of a Site or Stand-in: a fingerprint of the * fields WordPress edits in it. A save compares it with a fresh read, so * it doesn't overwrite a change made in Stand after the form was loaded. * Stand keeps no revisions: of two saves at the same moment, the last wins. * * @param array $item Site or Stand-in from Stand. * @param array $fields The fields that count. * @return array */ private static function with_revision( array $item, array $fields ): array { $counted = array(); foreach ( $fields as $field ) { $counted[ $field ] = $item[ $field ] ?? null; } $item['revision'] = substr( md5( (string) wp_json_encode( $counted ) ), 0, 16 ); return $item; } /** * Who could answer a new visitor on this Site right now. * * @return array>|WP_Error */ public function matches() { return Cache::remember( $this->key( 'matches' ), 30, function () { $all = $this->call( 'GET', '/v1/sites/matches' ); if ( is_wp_error( $all ) ) { return $all; } $matches = $all['siteMatches'][ $this->connection->site_id() ] ?? array(); return is_array( $matches ) ? array_values( $matches ) : array(); } ); } // --------------------------------------------------------------------- // Who answers new chats. // --------------------------------------------------------------------- /** * The current "Who answers new chats" choice, worked out from Stand-in * coverage, "I chat here" for this Site and the Stand-ins that are on. * * @return array|WP_Error mode: you_first, ai_every, only_you or nobody. */ public function who_answers() { return Cache::remember( $this->key( 'who_answers' ), 30, function () { $prefs = $this->call( 'GET', '/v1/reps/profile/sites' ); if ( is_wp_error( $prefs ) ) { return $prefs; } $coverage = $this->call( 'GET', '/v1/reps/me/standin/status' ); if ( is_wp_error( $coverage ) ) { return $coverage; } $standins = $this->standins(); if ( is_wp_error( $standins ) ) { return $standins; } $site_ids = $prefs['siteIds'] ?? null; $chats_here = null === $site_ids || in_array( $this->connection->site_id(), (array) $site_ids, true ); $on = array_values( array_filter( $standins, static function ( $standin ) { return ! empty( $standin['enabled'] ); } ) ); $ai = ! empty( $coverage['standinEnabled'] ) && ! empty( $on ); if ( $chats_here && $ai ) { $mode = 'you_first'; } elseif ( $ai ) { $mode = 'ai_every'; } elseif ( $chats_here ) { $mode = 'only_you'; } else { $mode = 'nobody'; } return array( 'mode' => $mode, 'chatsHere' => $chats_here, 'coverage' => ! empty( $coverage['standinEnabled'] ), 'answering' => array_map( static function ( $standin ) { return (string) ( $standin['displayName'] ?? '' ); }, $on ), ); } ); } /** * Runs a change only one request at a time makes. Two admins each turning * off another AI assistant, for one, could both see the other one answer, * and leave chats with nobody. * * @param string $what What changes. * @param callable $change The change. * @return mixed|WP_Error What the change returns, or why it wasn't made. */ private function one_at_a_time( string $what, callable $change ) { $token = Option_Queue::claim( self::OPTION_CLAIMS, $what, 2 * MINUTE_IN_SECONDS ); if ( '' === $token ) { return self::busy(); } $this->claims[ $what ] = $token; try { return $this->on_one_connection( $change ); } finally { unset( $this->claims[ $what ] ); Option_Queue::release( self::OPTION_CLAIMS, $what, $token ); } } /** * Makes a change in steps on one connection: nobody connects or * disconnects meanwhile, so every step's token is the connection's it * began with. Changes in other requests go on side by side. The claim on * connecting is renewed with the others at each step. A request that * began on another connection, made meanwhile, changes nothing: what it * knows is that connection's. * * @param callable $steps The change. * @param string|null $loaded For a request's changes, as on_loaded_connection() * takes it. * @return mixed|WP_Error What the change returns, or why it wasn't made. */ private function on_one_connection( callable $steps, ?string $loaded = null ) { if ( isset( $this->claims['connect'] ) ) { // Inside such a change already. return $steps(); } $connecting = $this->connection->hold( 2 * MINUTE_IN_SECONDS ); if ( '' === $connecting ) { return Connection::connecting(); } $identity = $this->connection->identity(); if ( ( $this->pinned ?? $this->connection->identity_seen() ) !== $identity || ( null !== $loaded && Connection::fingerprint( $identity ) !== $loaded ) ) { $this->connection->let_go( $connecting ); return self::reconnected( $identity ); } $this->claims['connect'] = $connecting; $pinned = $this->pinned; $this->pinned = $identity; try { return $steps(); } finally { $this->pinned = $pinned; unset( $this->claims['connect'] ); $this->connection->let_go( $connecting ); } } /** * Makes a request's changes on the connection its page was loaded on: * a page loaded before WordPress was connected again, maybe by another * admin or as another person, changes nothing, and neither does a * request that began before. Nobody connects or disconnects until the * changes are made, and each step checks the connection is still that * one, so no change meant for one Site goes to another. * * @param string|null $loaded Connection::fingerprint() of the page's * connection, or null when the request * doesn't say, such as one from a script, or * for a change no page asked for, such as a * scheduled one. * @param callable $change The request's changes. * @return mixed|WP_Error What the change returns, or why it wasn't made. */ public function on_loaded_connection( ?string $loaded, callable $change ) { return $this->on_one_connection( $change, $loaded ); } /** * Sets "Who answers new chats", one change at a time. * * @param string $mode you_first, ai_every or only_you. * @param string $standin_id The AI assistant to turn on for the AI modes. * @param bool $coverage_ok Whether the person agreed to turn on Stand-in * coverage, which reaches their other sites too. * @return array|WP_Error The new state. */ public function set_who_answers( string $mode, string $standin_id, bool $coverage_ok = false ) { return $this->one_at_a_time( 'who_answers', function () use ( $mode, $standin_id, $coverage_ok ) { return $this->change_who_answers( $mode, $standin_id, $coverage_ok ); } ); } /** * Sets "Who answers new chats". * * @param string $mode you_first, ai_every or only_you. * @param string $standin_id The AI assistant to turn on for the AI modes. * @param bool $coverage_ok Whether the person agreed to turn on Stand-in coverage. * @return array|WP_Error The new state. */ private function change_who_answers( string $mode, string $standin_id, bool $coverage_ok ) { if ( ! in_array( $mode, array( 'you_first', 'ai_every', 'only_you' ), true ) ) { return new WP_Error( 'stand_invalid', __( 'Choose who answers new chats.', 'stand-chat' ) ); } $ai = 'only_you' !== $mode; if ( $ai && '' === $standin_id ) { return new WP_Error( 'stand_invalid', __( 'Brief your AI assistant first.', 'stand-chat' ) ); } if ( ! $ai ) { // One that answers on all the person's sites would keep answering // here, and only Stand changes those: say so, and change nothing. $everywhere = $this->all_sites_standin_answering(); if ( is_wp_error( $everywhere ) ) { return $everywhere; } if ( '' !== $everywhere ) { return new WP_Error( 'stand_all_sites_standin', sprintf( /* translators: %s: AI assistant name */ __( '%s answers on all your sites, so it would keep answering here. Turn it off under Stand-ins in Stand, or choose another option.', 'stand-chat' ), $everywhere ) ); } // A teammate's AI assistant answers here too, and only they turn it off. $teammates = $this->teammates_assistant_answering(); if ( is_wp_error( $teammates ) ) { return $teammates; } if ( null !== $teammates ) { return new WP_Error( 'stand_teammate_standin', sprintf( /* translators: 1: AI assistant name, 2: its owner's name */ __( '%1$s, %2$s\'s AI assistant, also answers here, and only %2$s can turn it off. Ask %2$s, or choose another option.', 'stand-chat' ), $teammates['name'], $teammates['owner'] ) ); } } if ( 'ai_every' === $mode ) { // This Site's chats leave the person only when an AI assistant takes // them on every page: say so, and change nothing. $partial = $this->answers_on_some_pages_only( $standin_id ); if ( is_wp_error( $partial ) ) { return $partial; } if ( '' !== $partial ) { return new WP_Error( 'stand_some_pages_only', sprintf( /* translators: %1$s: AI assistant name */ __( '%1$s answers only on some pages, so chats on other pages would reach nobody. Let %1$s answer on every page under Stand-ins, or choose You first.', 'stand-chat' ), $partial ) ); } } // Stand-in coverage first: when it's off and the person hasn't agreed // to turn it on, nothing changes. if ( $ai ) { $coverage = $this->call( 'GET', '/v1/reps/me/standin/status' ); if ( is_wp_error( $coverage ) ) { return $coverage; } if ( empty( $coverage['standinEnabled'] ) ) { if ( ! $coverage_ok ) { return new WP_Error( 'stand_coverage_consent', __( 'Stand-in coverage is off for your Stand account, so no AI assistant answers. Turning it on lets the AI assistants you turned on for your other sites answer there too.', 'stand-chat' ) ); } $toggled = $this->call( 'POST', '/v1/reps/me/standin/toggle', array( 'body' => array( 'enabled' => true ) ) ); if ( is_wp_error( $toggled ) ) { return $toggled; } } } // A step that fails never leaves the Site with nobody answering: the AI // assistant is on before this Site's chats leave the person, and the // person gets them back before AI assistants stop. if ( $ai ) { $enabled = $this->set_standin_enabled( $standin_id, true ); if ( is_wp_error( $enabled ) ) { return $enabled; } } $result = $this->set_chats_here( 'ai_every' !== $mode ); if ( is_wp_error( $result ) ) { return $result; } if ( ! $ai ) { // Only this Site's own AI assistants are turned off: ones for all sites // change in Stand, and one that would answer here was refused above. $standins = $this->standins(); if ( is_wp_error( $standins ) ) { return $standins; } foreach ( $standins as $standin ) { if ( ! empty( $standin['enabled'] ) && ( $standin['siteId'] ?? null ) === $this->connection->site_id() ) { $off = $this->set_standin_enabled( (string) $standin['standinProfileId'], false ); if ( is_wp_error( $off ) ) { return $off; } } } } Cache::forget( $this->key( 'who_answers' ) ); Cache::forget( $this->key( 'matches' ) ); return $this->who_answers(); } /** * The name of an AI assistant that's on for all the person's sites, and so * answers here, or '' when there's none. * * @return string|WP_Error */ private function all_sites_standin_answering() { Cache::forget( $this->key( 'standins' ) ); $standins = $this->standins(); if ( is_wp_error( $standins ) ) { return $standins; } foreach ( $standins as $standin ) { if ( ! empty( $standin['enabled'] ) && null === ( $standin['siteId'] ?? null ) ) { // It answers only while Stand-in coverage is on. $coverage = $this->call( 'GET', '/v1/reps/me/standin/status' ); if ( is_wp_error( $coverage ) ) { return $coverage; } return empty( $coverage['standinEnabled'] ) ? '' : (string) ( $standin['displayName'] ?? '' ); } } return ''; } /** * A teammate's AI assistant that answers on this Site, which WordPress * can't turn off: its name and its owner's, or null when there's none. * * @return array{name: string, owner: string}|null|WP_Error */ private function teammates_assistant_answering() { $me = (string) ( Connection::stored_metadata()['user']['userId'] ?? '' ); if ( '' === $me ) { return null; } Cache::forget( $this->key( 'matches' ) ); $matches = $this->matches(); if ( is_wp_error( $matches ) ) { return $matches; } foreach ( $matches as $match ) { if ( 'rep' !== ( $match['responderType'] ?? '' ) && (string) ( $match['ownerRepId'] ?? '' ) !== $me ) { $owner = (string) ( $match['ownerName'] ?? '' ); return array( 'name' => (string) ( $match['name'] ?? '' ), 'owner' => '' !== $owner ? $owner : __( 'your teammate', 'stand-chat' ), ); } } return null; } /** * The AI assistant's name when AI answers every chat here and it's the * only one that's on and answers on every page; '' otherwise. * * @param string $id Stand-in ID. * @return string|WP_Error */ private function only_one_on_every_page( string $id ) { Cache::forget( $this->key( 'standins' ) ); $standins = $this->standins(); if ( is_wp_error( $standins ) ) { return $standins; } $name = ''; foreach ( $standins as $standin ) { if ( empty( $standin['enabled'] ) || '' !== (string) ( $standin['pathPrefix'] ?? '' ) ) { continue; } if ( (string) ( $standin['standinProfileId'] ?? '' ) !== $id ) { // Another one answers on every page. return ''; } $name = (string) ( $standin['displayName'] ?? '' ); } if ( '' === $name ) { // It's off, or answers on some pages already. return ''; } Cache::forget( $this->key( 'who_answers' ) ); $who = $this->who_answers(); if ( is_wp_error( $who ) ) { return $who; } return 'ai_every' === $who['mode'] ? $name : ''; } /** * The name of the AI assistant to turn on when it answers only on some * pages and no other one that's on answers on every page; '' otherwise. * * @param string $standin_id The AI assistant to turn on. * @return string|WP_Error */ private function answers_on_some_pages_only( string $standin_id ) { Cache::forget( $this->key( 'standins' ) ); $standins = $this->standins(); if ( is_wp_error( $standins ) ) { return $standins; } $name = ''; foreach ( $standins as $standin ) { $every_page = '' === (string) ( $standin['pathPrefix'] ?? '' ); $chosen = (string) ( $standin['standinProfileId'] ?? '' ) === $standin_id; if ( $every_page && ( $chosen || ! empty( $standin['enabled'] ) ) ) { return ''; } if ( $chosen ) { $name = (string) ( $standin['displayName'] ?? '' ); } } return $name; } /** * "I chat here" for this Site only. Stand keeps the person's choice for * their other sites. * * @param bool $chats_here Whether the connected user gets chats from this Site. * @return true|WP_Error */ private function set_chats_here( bool $chats_here ) { $result = $this->call( 'PUT', '/v1/reps/profile/sites/' . rawurlencode( $this->connection->site_id() ), array( 'body' => array( 'enabled' => $chats_here ) ) ); return is_wp_error( $result ) ? $result : true; } // --------------------------------------------------------------------- // Stand-ins (AI assistants). // --------------------------------------------------------------------- /** * The connected user's Stand-ins that can answer on this Site: those made * for it and those that answer on every Site. * * @return array>|WP_Error */ public function standins() { return Cache::remember( $this->key( 'standins' ), 30, function () { $list = $this->call( 'GET', '/v1/reps/me/standins' ); if ( is_wp_error( $list ) ) { return $list; } $site_id = $this->connection->site_id(); $primary = (string) State::get( 'standin_id', '' ); $out = array(); foreach ( (array) ( $list['standins'] ?? array() ) as $standin ) { if ( ! is_array( $standin ) || ! empty( $standin['deleted'] ) ) { continue; } $for = $standin['siteId'] ?? null; if ( null === $for || '' === $for || $site_id === $for ) { $out[] = $standin; } if ( '' !== $primary && ( $standin['standinProfileId'] ?? '' ) === $primary ) { // Name and look for the block editor's static previews. $preview = array( 'displayName' => (string) ( $standin['displayName'] ?? '' ), 'avatarUrl' => (string) ( $standin['avatarUrl'] ?? '' ), 'brand' => (string) ( $standin['displayBrandName'] ?? '' ), ); if ( State::get( 'standin_preview' ) !== $preview ) { State::set( 'standin_preview', $preview ); } } } return $out; } ); } /** * One Stand-in, with the revision of what WordPress edits in it. * * @param string $id Stand-in ID. * @return array|WP_Error */ public function standin( string $id ) { $standin = $this->call( 'GET', '/v1/reps/me/standins/' . rawurlencode( $id ) ); return is_wp_error( $standin ) ? $standin : self::with_revision( $standin, self::STANDIN_REVISION_FIELDS ); } /** * From WordPress, only this Site's own Stand-ins change. One that answers * on every Site is changed in Stand, where its other Sites are visible, and * one for another Site isn't WordPress's to change. * * @param string $id Stand-in ID. */ private function refuse_unless_this_sites( string $id ): ?WP_Error { // Read fresh: one moved in Stand to another Site, or to all of them, // meanwhile is no longer WordPress's to change. Cache::forget( $this->key( 'standins' ) ); $list = $this->standins(); if ( is_wp_error( $list ) ) { return $list; } foreach ( $list as $standin ) { if ( ( $standin['standinProfileId'] ?? '' ) !== $id ) { continue; } // standins() lists only this Site's and the every-Site ones. return empty( $standin['siteId'] ) ? new WP_Error( 'stand_generalist', __( 'This AI assistant answers on all your sites, so change it in Stand.', 'stand-chat' ) ) : null; } return self::not_this_sites(); } /** * Refuses an ID that isn't this Site's: WordPress changes only its own. */ private static function not_this_sites(): WP_Error { return new WP_Error( 'stand_not_found', __( 'That isn\'t this site\'s in Stand.', 'stand-chat' ), array( 'status' => 404 ) ); } /** * Whether an ID is among this Site's knowledge base items. * * @param string $key Field holding the ID. * @param string $id ID. * @param array>|WP_Error $items Sources or tasks. */ private static function listed( string $key, string $id, $items ): bool { foreach ( is_wp_error( $items ) ? array() : $items as $item ) { if ( (string) ( $item[ $key ] ?? '' ) === $id ) { return true; } } return false; } /** * Creates a Stand-in for this Site. New Stand-ins start Off. * * @param array $input StandinProfileInput fields. * @return array|WP_Error */ public function create_standin( array $input ) { $input['siteId'] = $this->connection->site_id(); // One at a time: the same brief sent twice at once would make two. return $this->one_at_a_time( 'create_standin', function () use ( $input ) { $created = $this->call( 'POST', '/v1/reps/me/standins', array( 'body' => self::standin_input( $input ) ) ); $this->forget_standins(); return is_wp_error( $created ) ? $created : self::with_revision( $created, self::STANDIN_REVISION_FIELDS ); } ); } /** * Saves a Stand-in. Stand keeps the Stand-in answering while it's saved; * when it has past conversations, Stand returns a new version with a new ID. * * @param string $id Stand-in ID. * @param array $input StandinProfileInput fields (complete). * @return array|WP_Error */ public function update_standin( string $id, array $input ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } if ( '' === (string) ( $input['pathPrefix'] ?? '' ) ) { return $this->save_standin( $id, $input ); } return $this->one_at_a_time( 'who_answers', function () use ( $id, $input ) { return $this->save_standin( $id, $input ); } ); } /** * Saves a Stand-in WordPress may change, unless it would narrow the only * AI assistant on every page while AI answers every chat. * * @param string $id Stand-in ID. * @param array $input StandinProfileInput fields (complete). * @return array|WP_Error */ private function save_standin( string $id, array $input ) { if ( '' !== (string) ( $input['pathPrefix'] ?? '' ) ) { // While AI answers every chat here, the only AI assistant on every // page can't narrow: chats elsewhere would reach nobody. $narrowing = $this->only_one_on_every_page( $id ); if ( is_wp_error( $narrowing ) ) { return $narrowing; } if ( '' !== $narrowing ) { return new WP_Error( 'stand_some_pages_only', sprintf( /* translators: %1$s: AI assistant name */ __( 'Chats on other pages would reach nobody: AI answers every chat here, and %1$s is the only AI assistant on every page. Choose You first under Who answers, or keep %1$s on every page.', 'stand-chat' ), $narrowing ) ); } } $saved = $this->call( 'PUT', '/v1/reps/me/standins/' . rawurlencode( $id ), array( 'body' => self::standin_input( $input ) ) ); if ( ! is_wp_error( $saved ) ) { $new_id = (string) ( $saved['standinProfileId'] ?? $id ); if ( $new_id !== $id && State::get( 'standin_id' ) === $id ) { State::set( 'standin_id', $new_id ); } } $this->forget_standins(); return is_wp_error( $saved ) ? $saved : self::with_revision( $saved, self::STANDIN_REVISION_FIELDS ); } /** * Turns a Stand-in On or Off from its switch. Off is refused while AI * answers every chat here and it's the only one on every page: chats * would reach nobody. * * @param string $id Stand-in ID. * @param bool $enabled On. * @return array|WP_Error */ public function switch_standin( string $id, bool $enabled ) { if ( $enabled ) { return $this->set_standin_enabled( $id, true ); } return $this->one_at_a_time( 'who_answers', function () use ( $id ) { $last = $this->only_one_on_every_page( $id ); if ( is_wp_error( $last ) ) { return $last; } if ( '' !== $last ) { return new WP_Error( 'stand_some_pages_only', sprintf( /* translators: %1$s: AI assistant name */ __( 'Chats would reach nobody: AI answers every chat here, and %1$s is the only AI assistant on every page. Choose You first under Who answers before turning %1$s off.', 'stand-chat' ), $last ) ); } return $this->set_standin_enabled( $id, false ); } ); } /** * Turns an AI assistant on or off, as Who answers does in its steps. * * @param string $id Stand-in ID. * @param bool $enabled On or off. * @return array|WP_Error */ public function set_standin_enabled( string $id, bool $enabled ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $result = $this->call( 'POST', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/enabled', array( 'body' => array( 'enabled' => $enabled ) ) ); $this->forget_standins(); return $result; } /** * A Stand-in's skills. * * @param string $id Stand-in ID. * @return array>|WP_Error */ public function skills( string $id ) { $result = $this->call( 'GET', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/skills' ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['skills'] ?? array() ) ); } /** * Updates a skill, such as Follow-up. * * @param string $id Stand-in ID. * @param string $key Skill key: followup, customerDiscovery or labeling. * @param bool $enabled On. * @param array $config Skill configuration. * @return array|WP_Error */ public function update_skill( string $id, string $key, bool $enabled, array $config = array() ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $body = array( 'enabled' => $enabled ); if ( ! empty( $config ) ) { $body['config'] = $config; } return $this->call( 'PUT', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/skills/' . rawurlencode( $key ), array( 'body' => $body ) ); } /** * Generated looks before a Stand-in exists. * * @param array $brief displayName, greeting, prompt. * @return array>|WP_Error */ public function preview_looks( array $brief ) { $result = $this->call( 'POST', '/v1/reps/me/standins/preview-avatar-candidates', array( 'body' => array( 'displayName' => (string) ( $brief['displayName'] ?? '' ), 'greeting' => (string) ( $brief['greeting'] ?? '' ), 'prompt' => (string) ( $brief['prompt'] ?? '' ), 'siteId' => $this->connection->site_id(), ), 'timeout' => 60, ) ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['candidates'] ?? array() ) ); } /** * Generated looks for a Stand-in. * * @param string $id Stand-in ID. * @return array>|WP_Error */ public function looks( string $id ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $result = $this->call( 'POST', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/avatar-candidates', array( 'body' => array(), 'timeout' => 60, ) ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['candidates'] ?? array() ) ); } /** * Uses one generated look. * * @param string $id Stand-in ID. * @param string $candidate_id Candidate ID. * @return array|WP_Error */ public function select_look( string $id, string $candidate_id ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $result = $this->call( 'POST', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/avatar/select-candidate', array( 'body' => array( 'candidateId' => $candidate_id ) ) ); $this->forget_standins(); return $result; } /** * Removes a Stand-in's photo. * * @param string $id Stand-in ID. * @return array|WP_Error */ public function remove_look( string $id ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $result = $this->call( 'DELETE', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/avatar' ); $this->forget_standins(); return $result; } /** * Uploads an image from the Media Library as a Stand-in's photo. * * @param string $id Stand-in ID. * @param int $attachment_id Attachment ID (the Site Icon by default). * @return array|WP_Error */ public function upload_look( string $id, int $attachment_id ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $file = get_attached_file( $attachment_id ); $type = (string) get_post_mime_type( $attachment_id ); if ( ! $file || ! is_readable( $file ) || ! in_array( $type, array( 'image/png', 'image/jpeg', 'image/webp' ), true ) ) { return new WP_Error( 'stand_invalid', __( 'Use a PNG, JPEG or WebP image.', 'stand-chat' ) ); } $copy = ''; $editor = wp_get_image_editor( $file ); if ( ! is_wp_error( $editor ) ) { // Upload a square, bounded copy rather than the original file. $editor->resize( 512, 512, true ); $saved = $editor->save( trailingslashit( get_temp_dir() ) . 'stand-chat-look-' . wp_generate_password( 12, false ) . '.png', 'image/png' ); if ( ! is_wp_error( $saved ) && ! empty( $saved['path'] ) ) { $file = $saved['path']; $copy = $saved['path']; $type = 'image/png'; } } $contents = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file. if ( '' !== $copy ) { wp_delete_file( $copy ); } if ( false === $contents || strlen( $contents ) > 2 * MB_IN_BYTES ) { return new WP_Error( 'stand_invalid', __( 'Use an image smaller than 2 MB.', 'stand-chat' ) ); } $result = $this->call( 'POST', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/avatar', array( 'multipart' => array( 'name' => 'file', 'filename' => 'look.' . ( 'image/png' === $type ? 'png' : ( 'image/webp' === $type ? 'webp' : 'jpg' ) ), 'type' => $type, 'contents' => $contents, ), 'timeout' => 30, ) ); $this->forget_standins(); return $result; } /** * Knowledge bases attached to a Stand-in. * * @param string $id Stand-in ID. * @return array|WP_Error */ public function standin_knowledge( string $id ) { return $this->call( 'GET', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/knowledge-bases' ); } /** * Attaches this Site's knowledge base to a Stand-in, within the plan's limit. * * @param string $id Stand-in ID. * @return array|WP_Error */ public function attach_site_knowledge( string $id ) { $refused = $this->refuse_unless_this_sites( $id ); if ( $refused ) { return $refused; } $kb = $this->ensure_knowledge_base(); if ( is_wp_error( $kb ) ) { return $kb; } $attached = $this->standin_knowledge( $id ); if ( is_wp_error( $attached ) ) { return $attached; } // The other knowledge bases stay attached, including ones from Stand. $ids = array_values( array_map( 'strval', (array) ( $attached['knowledgeBaseIds'] ?? array() ) ) ); if ( in_array( $kb['knowledgeBaseId'], $ids, true ) ) { return $attached; } $max = (int) ( $attached['maxAttached'] ?? 1 ); if ( $max > 0 && count( $ids ) >= $max ) { return new WP_Error( 'stand_plan_limit', __( 'This AI assistant already uses as many knowledge bases as your plan allows. Manage them in Stand.', 'stand-chat' ) ); } // Stand replaces the whole list: it's read just before, so a change made // in Stand meanwhile is kept. return $this->call( 'PUT', '/v1/reps/me/standins/' . rawurlencode( $id ) . '/knowledge-bases', array( 'body' => array( 'knowledgeBaseIds' => array_merge( $ids, array( (string) $kb['knowledgeBaseId'] ) ) ) ) ); } /** * Drops cached Stand-in data. */ private function forget_standins(): void { Cache::forget( $this->key( 'standins' ) ); Cache::forget( $this->key( 'who_answers' ) ); Cache::forget( $this->key( 'matches' ) ); } /** * A complete StandinProfileInput from the given fields. * * @param array $input Fields. * @return array */ private static function standin_input( array $input ): array { $out = array( 'name' => (string) ( $input['name'] ?? $input['displayName'] ?? '' ), 'displayName' => (string) ( $input['displayName'] ?? '' ), 'prompt' => (string) ( $input['prompt'] ?? '' ), 'greeting' => isset( $input['greeting'] ) ? (string) $input['greeting'] : null, 'siteId' => isset( $input['siteId'] ) ? (string) $input['siteId'] : null, 'pathPrefix' => isset( $input['pathPrefix'] ) && '' !== $input['pathPrefix'] ? (string) $input['pathPrefix'] : null, ); if ( array_key_exists( 'displayBrandName', $input ) ) { $out['displayBrandName'] = '' === (string) $input['displayBrandName'] ? null : (string) $input['displayBrandName']; } if ( array_key_exists( 'toolsEnabled', $input ) && is_string( $input['toolsEnabled'] ) ) { $out['toolsEnabled'] = $input['toolsEnabled']; } return $out; } // --------------------------------------------------------------------- // Knowledge. // --------------------------------------------------------------------- /** * Knowledge bases with the plan's page limits and usage. * * @param bool $fresh Skip the cache. * @return array|WP_Error */ public function knowledge_overview( bool $fresh = false ) { if ( $fresh ) { Cache::forget( $this->key( 'kb_overview' ) ); } return Cache::remember( $this->key( 'kb_overview' ), 60, function () { return $this->call( 'GET', '/v1/knowledge-bases' ); } ); } /** * This Site's own knowledge base: the one WordPress made for it, owned by * the connected user. Null when there's none yet. * * @return array|null|WP_Error */ public function own_knowledge_base() { $identity = $this->pinned ?? $this->connection->identity(); $overview = $this->knowledge_overview(); if ( is_wp_error( $overview ) ) { return $overview; } $known = (string) State::get( 'knowledge_base_id', '' ); $found = null; $best = 0; foreach ( (array) ( $overview['knowledgeBases'] ?? array() ) as $kb ) { if ( ! is_array( $kb ) || empty( $kb['ownedByCurrentRep'] ) || empty( $kb['knowledgeBaseId'] ) ) { continue; } if ( '' !== $known && $kb['knowledgeBaseId'] === $known ) { return $kb; } $match = $this->made_here( $kb ); if ( $match > $best ) { $found = $kb; $best = $match; } } // After reconnecting or reinstalling, reuse the Site's own knowledge base. if ( null !== $found ) { $this->remember_knowledge_base( $identity, (string) $found['knowledgeBaseId'] ); } return $found; } /** * Remembers this Site's knowledge base for the connection that found or * made it. Connected again meanwhile, maybe as another person, the new * connection finds its own instead. * * @param string $identity Connection::identity() when it was asked for. * @param string $id Knowledge base ID. */ private function remember_knowledge_base( string $identity, string $id ): void { $this->connection->as_connected( $identity, static function () use ( $id ): bool { State::set( 'knowledge_base_id', $id ); return true; } ); } /** * Whether WordPress made this knowledge base for this site. Stand doesn't * say, so the plugin knows it by the description it gave it: 2 when it * ends with this Site's ID; 1 when, in whatever language, it names * WordPress and this site's domain, as one made for a Site this one * replaced does, so what was read stays; 0 otherwise. * * @param array $kb Knowledge base. */ private function made_here( array $kb ): int { $description = (string) ( $kb['description'] ?? '' ); if ( 1 === preg_match( '/' . preg_quote( self::SITE_MARKER, '/' ) . '([A-Za-z0-9_-]+)/', $description, $site ) && $site[1] === $this->connection->site_id() ) { return 2; } // The domain on its own, so example.com doesn't match shop.example.com. $pattern = '/(?|WP_Error */ public function ensure_knowledge_base() { $own = $this->own_knowledge_base(); if ( null !== $own ) { return $own; } // One request makes it: two adding their first pages at once would each // make one, and their pages would end up apart. return $this->one_at_a_time( 'knowledge_base', function () { // Made by the request that made it just before? Cache::forget( $this->key( 'kb_overview' ) ); $own = $this->own_knowledge_base(); return null !== $own ? $own : $this->create_knowledge_base(); } ); } /** * Creates this Site's knowledge base. * * @return array|WP_Error */ private function create_knowledge_base() { $name = sprintf( /* translators: %s: site title */ __( '%s website', 'stand-chat' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ); $body = array( 'name' => mb_substr( $name, 0, 120 ), 'description' => sprintf( /* translators: %s: domain. Keep "WordPress" and the domain as they are: the plugin finds its knowledge base by them. */ __( 'Pages picked in WordPress on %s. Managed by Stand Chat for WordPress.', 'stand-chat' ), Environment::home_domain() ) . ' ' . self::SITE_MARKER . $this->connection->site_id(), ); $identity = $this->pinned ?? $this->connection->identity(); $created = $this->call( 'POST', '/v1/knowledge-bases', array( 'body' => $body ) ); if ( is_wp_error( $created ) ) { return $created; } $this->remember_knowledge_base( $identity, (string) $created['knowledgeBaseId'] ); Cache::forget( $this->key( 'kb_overview' ) ); return $created; } /** * Sources in this Site's knowledge base. * * @return array>|WP_Error */ public function sources() { $kb = (string) State::get( 'knowledge_base_id', '' ); if ( '' === $kb ) { return array(); } return Cache::remember( $this->key( 'kb_sources' ), 15, function () use ( $kb ) { $result = $this->call( 'GET', '/v1/knowledge-bases/' . rawurlencode( $kb ) . '/sources' ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['sources'] ?? array() ) ); } ); } /** * Ingestion tasks in this Site's knowledge base. * * @return array>|WP_Error */ public function tasks() { $kb = (string) State::get( 'knowledge_base_id', '' ); if ( '' === $kb ) { return array(); } $result = $this->call( 'GET', '/v1/knowledge-bases/' . rawurlencode( $kb ) . '/tasks' ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['tasks'] ?? array() ) ); } /** * Adds one page: a crawl at depth 0 reads exactly that page and follows no * links. * * @param string $url Page address. * @return array|WP_Error The ingestion task. */ public function add_page( string $url ) { // On one connection: its source goes in that connection's knowledge base. return $this->on_one_connection( function () use ( $url ) { $kb = $this->ensure_knowledge_base(); if ( is_wp_error( $kb ) ) { return $kb; } $task = $this->call( 'POST', '/v1/knowledge-bases/' . rawurlencode( (string) $kb['knowledgeBaseId'] ) . '/crawls', array( 'body' => array( 'domain' => Environment::normalize_domain( $url ), 'urlPrefix' => $url, 'maxDepth' => 0, ), ) ); Cache::forget( $this->key( 'kb_sources' ) ); Cache::forget( $this->key( 'kb_overview' ) ); if ( ! is_wp_error( $task ) && '' === (string) ( $task['sourceId'] ?? '' ) ) { // A page linked to no source could only be unpicked. return new WP_Error( 'stand_server_error', __( 'Stand had a problem. Try again in a moment.', 'stand-chat' ) ); } return $task; } ); } /** * Removes a page's source and what was read from it. * * @param string $source_id Source ID. * @return array|WP_Error */ public function remove_source( string $source_id ) { $result = $this->call( 'DELETE', '/v1/knowledge-bases/sources/' . rawurlencode( $source_id ) ); Cache::forget( $this->key( 'kb_sources' ) ); Cache::forget( $this->key( 'kb_overview' ) ); return $result; } /** * Reads a page again. The old version stays until the new one is ready. * * @param string $source_id Source ID. * @return array|WP_Error The ingestion task. */ public function read_again( string $source_id ) { $result = $this->call( 'POST', '/v1/knowledge-bases/sources/' . rawurlencode( $source_id ) . '/recrawl', array( 'body' => array() ) ); Cache::forget( $this->key( 'kb_sources' ) ); return $result; } /** * Cancels reading. * * @param string $task_id Task ID. * @return array|WP_Error */ public function cancel_task( string $task_id ) { $tasks = $this->tasks(); if ( is_wp_error( $tasks ) ) { return $tasks; } if ( ! self::listed( 'taskId', $task_id, $tasks ) ) { return self::not_this_sites(); } $result = $this->call( 'POST', '/v1/knowledge-bases/tasks/' . rawurlencode( $task_id ) . '/cancel', array( 'body' => array() ) ); Cache::forget( $this->key( 'kb_sources' ) ); return $result; } /** * "What does {name} find?" * * @param string $query Question. * @return array>|WP_Error */ public function search( string $query ) { $kb = (string) State::get( 'knowledge_base_id', '' ); if ( '' === $kb ) { return array(); } $result = $this->call( 'POST', '/v1/knowledge-bases/' . rawurlencode( $kb ) . '/search', array( 'body' => array( 'query' => $query, 'topK' => 5, ), ) ); return is_wp_error( $result ) ? $result : array_values( (array) ( $result['results'] ?? array() ) ); } /** * Reads the WordPress sitemap ([Pro]); nightly updates are [Business]. * * @param string $sitemap_url Sitemap address. * @param bool $nightly Nightly sitemap updates. * @return array|WP_Error The ingestion task. */ public function add_sitemap( string $sitemap_url, bool $nightly ) { $kb = $this->ensure_knowledge_base(); if ( is_wp_error( $kb ) ) { return $kb; } // Once, one request at a time: Stand makes another source for the same // sitemap, and WordPress shows only one. return $this->one_at_a_time( 'sitemap', function () use ( $kb, $sitemap_url, $nightly ) { Cache::forget( $this->key( 'kb_sources' ) ); $sources = $this->sources(); if ( is_wp_error( $sources ) ) { return $sources; } foreach ( $sources as $source ) { if ( 'sitemap' === ( $source['sourceType'] ?? '' ) && strtolower( untrailingslashit( (string) ( $source['originalUrl'] ?? '' ) ) ) === strtolower( untrailingslashit( $sitemap_url ) ) ) { return $source; } } $body = array( 'sitemapUrl' => $sitemap_url ); if ( $nightly ) { $body['dailyRefreshEnabled'] = true; } $result = $this->call( 'POST', '/v1/knowledge-bases/' . rawurlencode( (string) $kb['knowledgeBaseId'] ) . '/sitemaps', array( 'body' => $body ) ); Cache::forget( $this->key( 'kb_sources' ) ); Cache::forget( $this->key( 'kb_overview' ) ); return $result; } ); } /** * Turns nightly sitemap updates on or off ([Business]). * * @param string $source_id Sitemap source ID. * @param bool $enabled On. * @return array|WP_Error */ public function set_sitemap_refresh( string $source_id, bool $enabled ) { $sources = $this->sources(); if ( is_wp_error( $sources ) ) { return $sources; } if ( ! self::listed( 'sourceId', $source_id, $sources ) ) { return self::not_this_sites(); } $result = $this->call( 'PUT', '/v1/knowledge-bases/sources/' . rawurlencode( $source_id ) . '/sitemap-refresh', array( 'body' => array( 'enabled' => $enabled ) ) ); Cache::forget( $this->key( 'kb_sources' ) ); return $result; } // --------------------------------------------------------------------- // Numbers and plan. // --------------------------------------------------------------------- /** * This week on this Site: chat starts and minutes in conversations, from * Stand's statistics for the last seven days. * * @return array|WP_Error */ public function week() { return Cache::remember( $this->key( 'week' ), 300, function () { $stats = $this->call( 'GET', '/v1/stats', array( 'query' => array( 'scope' => 'site', 'scopeId' => $this->connection->site_id(), 'bucketType' => 'd', ), ) ); if ( is_wp_error( $stats ) ) { return $stats; } $buckets = array_slice( array_values( (array) ( $stats['buckets'] ?? array() ) ), -7 ); $days = array(); $chats = 0; $seconds = 0; foreach ( $buckets as $bucket ) { $chats += (int) ( $bucket['chatStart'] ?? 0 ); $seconds += (int) ( $bucket['sessionLengthSum'] ?? 0 ); $days[] = array( 'day' => self::bucket_day( (string) ( $bucket['bucketKey'] ?? '' ) ), 'chats' => (int) ( $bucket['chatStart'] ?? 0 ), ); } return array( 'chats' => $chats, 'minutes' => (int) round( $seconds / 60 ), 'days' => $days, ); } ); } /** * The date of a day bucket: Stand keys them d:YYYYMMDD (UTC). * * @param string $key Bucket key. * @return string YYYY-MM-DD, or the key as it is. */ private static function bucket_day( string $key ): string { return 1 === preg_match( '/^d:(\d{4})(\d{2})(\d{2})$/', $key, $m ) ? $m[1] . '-' . $m[2] . '-' . $m[3] : $key; } /** * Plan, quota and usage. * * @param bool $fresh Skip the cache. * @return array|WP_Error */ public function plan( bool $fresh = false ) { if ( $fresh ) { Cache::forget( $this->key( 'plan' ) ); } return Cache::remember( $this->key( 'plan' ), 300, function () { $plan = $this->call( 'GET', '/v1/org/plan' ); if ( is_wp_error( $plan ) ) { return $plan; } $usage = $this->call( 'GET', '/v1/org/usage' ); if ( is_wp_error( $usage ) ) { return $usage; } return array( 'plan' => $plan, 'usage' => $usage, ); } ); } // --------------------------------------------------------------------- // Prompts and team. // --------------------------------------------------------------------- /** * The Site AI prompt. * * @return array|WP_Error */ public function site_prompt() { return $this->call( 'GET', $this->site_path( '/prompt' ) ); } /** * Saves the Site AI prompt (owner only). * * @param string $content Prompt. * @return array|WP_Error */ public function save_site_prompt( string $content ) { return $this->call( 'PUT', $this->site_path( '/prompt' ), array( 'body' => array( 'content' => $content ) ) ); } /** * Clears the Site AI prompt (owner only). * * @return array|WP_Error */ public function delete_site_prompt() { return $this->call( 'DELETE', $this->site_path( '/prompt' ) ); } /** * The Team AI prompt, read-only. * * @return array|WP_Error */ public function team_prompt() { return $this->call( 'GET', '/v1/reps/me/org-prompt' ); } /** * Team members and invitations. * * @return array|WP_Error */ public function team() { return Cache::remember( $this->key( 'team' ), 300, function () { return $this->call( 'GET', '/v1/team' ); } ); } }