label, default. Every plan. * Keys match the widget's i18n keys; notFound is used by the service. * * @return array */ public static function wording_fields() { return array( 'ask' => array( __( 'Chat button', 'askmypages' ), __( 'Ask a question', 'askmypages' ) ), 'placeholder' => array( __( 'Question box', 'askmypages' ), __( 'Type your question…', 'askmypages' ) ), 'disclaimer' => array( __( 'Note under answers', 'askmypages' ), __( 'Answers can contain mistakes; check the sources.', 'askmypages' ) ), 'notFound' => array( __( 'When nothing is found', 'askmypages' ), __( 'I couldn’t find an answer to that. Try asking in different words, or use the search box.', 'askmypages' ) ), 'thinking' => array( __( 'While answering', 'askmypages' ), __( 'Looking that up…', 'askmypages' ) ), 'answerTitle' => array( __( 'Search answer card title', 'askmypages' ), __( 'Get a direct answer', 'askmypages' ) ), 'answerIntro' => array( __( 'Search answer card text', 'askmypages' ), __( 'A short answer, with links to the pages it comes from.', 'askmypages' ) ), 'followUp' => array( __( 'Follow-up box', 'askmypages' ), __( 'Ask a follow-up…', 'askmypages' ) ), 'visitorLimit' => array( __( 'Daily question limit reached', 'askmypages' ), __( 'You have asked a lot of questions today. Please come back tomorrow, or use the search box.', 'askmypages' ) ), 'busy' => array( __( 'Too many questions at once', 'askmypages' ), __( 'Lots of questions right now. Please wait a minute and try again.', 'askmypages' ) ), 'paused' => array( __( 'Answers paused (monthly limit)', 'askmypages' ), __( 'Answers are paused right now. The search box still works.', 'askmypages' ) ), 'off' => array( __( 'Answers turned off', 'askmypages' ), __( 'Answers are not available right now.', 'askmypages' ) ), 'failed' => array( __( 'Something went wrong', 'askmypages' ), __( 'Something went wrong. Please try again.', 'askmypages' ) ), ); } /** * Saved wording. * * @return array */ public static function wording() { $saved = get_option( self::OPT_WORDING, array() ); return is_array( $saved ) ? $saved : array(); } /** * The widget's text: the site's wording where set, otherwise defaults. * * @return array */ private static function texts() { $saved = self::wording(); $out = array(); foreach ( self::wording_fields() as $key => $field ) { $own = (string) ( $saved[ $key ] ?? '' ); $out[ $key ] = '' !== $own ? $own : $field[1]; } return $out; } /** * Who sees the chat. * * @return array{who:string,roles:string[]} */ public static function access() { $access = get_option( self::OPT_ACCESS, array() ); $access = is_array( $access ) ? $access : array(); $who = in_array( $access['who'] ?? '', array( 'everyone', 'logged_in', 'roles' ), true ) ? $access['who'] : 'everyone'; return array( 'who' => $who, 'roles' => array_values( array_map( 'sanitize_key', (array) ( $access['roles'] ?? array() ) ) ), ); } /** * Whether the current visitor may see the chat. This decides who is * shown it; the content behind it is public either way. * * @return bool */ public static function visible_to_visitor() { return Access::allowed( 'chat' ); } /** * Widget appearance: colours from the site (default) or a custom accent, * which corner the button sits in, and the assistant's picture. * * @return array{mode:string,accent:string,position:string,badge:bool,avatar:string,avatar_id:int} */ public static function look() { $look = get_option( self::OPT_LOOK, array() ); $look = is_array( $look ) ? $look : array(); $avatar = (string) ( $look['avatar'] ?? 'auto' ); $theme = (string) ( $look['theme'] ?? '' ); if ( ! in_array( $theme, array( 'site', 'light', 'dark' ), true ) ) { $theme = 'site'; } // 'mode' is the older setting: 'custom' meant "my accent colour on the // site's colours". It now maps to theme 'site' with an accent. $accent_on = isset( $look['accent_on'] ) ? ! empty( $look['accent_on'] ) : ( ( $look['mode'] ?? 'site' ) === 'custom' ); return array( 'theme' => $theme, // Kept for the widget: 'custom' when an accent colour is chosen. 'mode' => $accent_on ? 'custom' : 'site', 'accent_on' => $accent_on, 'accent' => sanitize_hex_color( $look['accent'] ?? '' ) ? $look['accent'] : '#0066e2', 'position' => ( $look['position'] ?? 'right' ) === 'left' ? 'left' : 'right', // "Powered by AskMyPages" under the chat: only if the owner opts in. 'badge' => ! empty( $look['badge'] ), // The picture in the chat header and on the button: the site icon // (auto, falling back to the assistant's initial), an image from // the Media Library, or none (a chat mark). 'avatar' => in_array( $avatar, array( 'image', 'none' ), true ) ? $avatar : 'auto', 'avatar_id' => (int) ( $look['avatar_id'] ?? 0 ), ); } /** * Address of the assistant's picture, or '' for the initial / chat mark. * * @return string */ public static function avatar_url() { $look = self::look(); if ( 'none' === $look['avatar'] ) { return ''; } if ( 'image' === $look['avatar'] && $look['avatar_id'] && wp_attachment_is_image( $look['avatar_id'] ) ) { return (string) wp_get_attachment_image_url( $look['avatar_id'], 'thumbnail' ); } return 'auto' === $look['avatar'] ? (string) get_site_icon_url( 96 ) : ''; } /** * Registers hooks. */ public static function init() { add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ) ); add_shortcode( 'askmypages_chat', array( __CLASS__, 'shortcode' ) ); add_action( 'admin_post_askmypages_chat', array( __CLASS__, 'handle_save' ) ); add_action( 'wp_ajax_askmypages_try', array( __CLASS__, 'handle_try' ) ); add_filter( 'render_block_data', array( __CLASS__, 'answer_slot_enter' ) ); add_filter( 'render_block', array( __CLASS__, 'answer_slot_block' ), 10, 2 ); add_action( 'loop_start', array( __CLASS__, 'answer_slot_loop' ) ); } /** * Cached overview: settings, features, plan and usage. * * @return array */ public static function data() { $data = get_option( self::OPTION, array() ); return is_array( $data ) ? $data : array(); } /** * A setting from the last overview. * * @param string $key Setting name. * @param mixed $fallback Value when the setting is missing. * @return mixed */ public static function setting( $key, $fallback = null ) { $data = self::data(); return $data['settings'][ $key ] ?? $fallback; } /** * A plan feature from the last overview. * * @param string $key Feature name. * @return bool */ public static function feature( $key ) { $data = self::data(); return ! empty( $data['features'][ $key ] ); } /** * Whether visitors see the chat. * * @return bool */ public static function enabled() { return Keys::is_connected() && (bool) self::setting( 'enabled', false ) && get_option( 'askmypages_search_key' ); } /** * Refreshes the overview from the service (at most once a minute). * * @param bool $force Skip the one-minute cache. * @return array|null */ public static function refresh( $force = false ) { if ( ! Keys::is_connected() ) { return null; } if ( ! $force && get_transient( self::TRANSIENT ) ) { return self::data(); } $result = Api::signed( 'GET', '/v1/plugin/chat' ); if ( is_wp_error( $result ) || ! is_array( $result ) ) { return null; } update_option( self::OPTION, $result, true ); set_transient( self::TRANSIENT, 1, MINUTE_IN_SECONDS ); return $result; } /** * Saves the chat settings form. */ public static function handle_save() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'askmypages' ), 403 ); } check_admin_referer( 'askmypages_chat' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked above. $field = static function ( $key ) { return isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; }; $suggestions = isset( $_POST['suggestions'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['suggestions'] ) ) : array(); // Locked (paid) boxes are not submitted, so their saved text is kept. $wording = self::wording(); $posted = isset( $_POST['wording'] ) && is_array( $_POST['wording'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['wording'] ) ) : array(); foreach ( array_keys( self::wording_fields() ) as $key ) { if ( isset( $posted[ $key ] ) ) { $wording[ $key ] = mb_substr( trim( $posted[ $key ] ), 0, 240 ); } } update_option( self::OPT_WORDING, array_filter( $wording, 'strlen' ), false ); $payload = array( 'enabled' => isset( $_POST['enabled'] ), 'siteName' => mb_substr( html_entity_decode( get_bloginfo( 'name' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 0, 120 ), 'timezone' => wp_timezone_string(), 'answersCap' => min( 1000000, absint( $field( 'answers_cap' ) ) ), 'visitorDailyLimit' => min( 1000, absint( $field( 'visitor_daily_limit' ) ) ), 'name' => mb_substr( $field( 'name' ), 0, 40 ), 'greeting' => mb_substr( $field( 'greeting' ), 0, 240 ), 'tone' => in_array( $field( 'tone' ), array( 'friendly', 'professional', 'concise' ), true ) ? $field( 'tone' ) : 'friendly', 'suggestions' => array_values( array_filter( array_map( static fn( $s ) => mb_substr( $s, 0, 120 ), array_slice( $suggestions, 0, 4 ) ) ) ), 'questionsToChat' => isset( $_POST['questions_to_chat'] ), 'notFound' => (string) ( $wording['notFound'] ?? '' ), ); $accent = sanitize_hex_color( $field( 'accent' ) ); $theme = $field( 'theme' ); $avatar = $field( 'avatar' ); $avatar_id = absint( $field( 'avatar_id' ) ); if ( 'image' === $avatar && ! ( $avatar_id && wp_attachment_is_image( $avatar_id ) ) ) { $avatar = 'auto'; } update_option( self::OPT_LOOK, array( 'theme' => in_array( $theme, array( 'light', 'dark' ), true ) ? $theme : 'site', 'accent_on' => isset( $_POST['accent_on'] ) && $accent, 'mode' => isset( $_POST['accent_on'] ) && $accent ? 'custom' : 'site', 'accent' => $accent ? $accent : '#0066e2', 'position' => 'left' === $field( 'position' ) ? 'left' : 'right', 'badge' => isset( $_POST['show_badge'] ), 'avatar' => in_array( $avatar, array( 'image', 'none' ), true ) ? $avatar : 'auto', 'avatar_id' => 'image' === $avatar ? $avatar_id : 0, ), false ); // phpcs:enable WordPress.Security.NonceVerification.Missing // Offsets like "+02:00" are not IANA zones; the service falls back to UTC. $result = Api::signed( 'PUT', '/v1/plugin/chat', wp_json_encode( $payload ) ); if ( is_wp_error( $result ) ) { set_transient( 'askmypages_notice_error', $result->get_error_message(), MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( 'askmypages_notice', 'error', Admin::tab_url( 'chat' ) ) ); exit; } update_option( self::OPTION, $result, true ); set_transient( self::TRANSIENT, 1, MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( 'askmypages_notice', 'chat_saved', Admin::tab_url( 'chat' ) ) ); exit; } /** * "Try it" in wp-admin: one question, answered from the test allowance. */ public static function handle_try() { check_ajax_referer( 'askmypages_try' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'askmypages' ) ), 403 ); } $question = isset( $_POST['question'] ) ? mb_substr( sanitize_text_field( wp_unslash( $_POST['question'] ) ), 0, 500 ) : ''; $convo = isset( $_POST['conversationId'] ) ? sanitize_text_field( wp_unslash( $_POST['conversationId'] ) ) : ''; if ( '' === $question ) { wp_send_json_error( array( 'message' => __( 'Type a question first.', 'askmypages' ) ) ); } $body = array( 'message' => $question, // The admin trying it sees what their own roles may see. 'viewer' => Access::viewer(), ); if ( preg_match( '/^[0-9a-f-]{36}$/', $convo ) ) { $body['conversationId'] = $convo; } $result = Api::signed( 'POST', '/v1/plugin/chat/try', wp_json_encode( $body ), 60 ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( $result ); } /** * Whether the answer card was already placed on this page. * * @var bool */ private static $slot_placed = false; /** * Where the answer card goes on a search results page: an empty element * the chat script fills in. * * @param string $align Block alignment class to match, if any. * @return string */ private static function answer_slot( $align = '' ) { if ( self::$slot_placed || is_admin() || ! is_search() || ! self::enabled() || ! self::visible_to_visitor() ) { return ''; } $query = trim( (string) get_search_query( false ) ); if ( '' === $query ) { return ''; } self::$slot_placed = true; return sprintf( '
', esc_attr( trim( 'askmypages-answer ' . $align ) ), esc_attr( mb_substr( $query, 0, 500 ) ) ); } /** * Query blocks being rendered: true for one that shows the main query. * * @var bool[] */ private static $queries = array(); /** * Notes each query block as it starts rendering (before its inner * blocks), so the results list inside knows whether it is the main one. * * @param array $block Block data. * @return array */ public static function answer_slot_enter( $block ) { if ( 'core/query' === ( $block['blockName'] ?? '' ) ) { self::$queries[] = ! empty( $block['attrs']['query']['inherit'] ); } return $block; } /** * Block themes: the card goes just above the search results list (or * the "no results" message) inside the main query, so the theme's layout * gives it the same width as the results. * * @param string $html Rendered block. * @param array $block Block data. * @return string */ public static function answer_slot_block( $html, $block ) { $name = $block['blockName'] ?? ''; if ( 'core/query' === $name ) { array_pop( self::$queries ); } elseif ( in_array( $name, array( 'core/post-template', 'core/query-no-results' ), true ) && end( self::$queries ) ) { // Same alignment as the list, so the same width. $align = preg_match( '/^\s*<[a-z]+[^>]*\sclass="[^"]*\b(align(?:wide|full))\b/', $html, $m ) ? $m[1] : ''; return self::answer_slot( $align ) . $html; } return $html; } /** * Classic themes: the card goes at the start of the results loop. * * @param \WP_Query $query Query. */ public static function answer_slot_loop( $query ) { if ( wp_is_block_theme() || ! $query->is_main_query() ) { return; } echo self::answer_slot(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with esc_attr above. } /** * Settings for the widget script. * * @return array */ private static function config() { $texts = self::texts(); $query = is_search() ? get_search_query( false ) : ''; return array( 'api' => untrailingslashit( ASKMYPAGES_PUBLIC_API_URL ), 'key' => (string) get_option( 'askmypages_search_key' ), // Proves a logged-in user's roles to the service (members-only content). 'member' => Access::member_token(), 'name' => (string) self::setting( 'name', '' ), 'greeting' => (string) self::setting( 'greeting', '' ), 'suggestions' => array_values( (array) self::setting( 'suggestions', array() ) ), 'questionsToChat' => (bool) self::setting( 'questionsToChat', false ), 'badge' => self::look()['badge'], 'avatar' => self::avatar_url(), 'query' => mb_substr( (string) $query, 0, 500 ), 'floating' => true, 'appearance' => self::look(), 'assets' => plugins_url( 'assets/', __DIR__ ), 'i18n' => array_merge( array( 'askAbout' => __( 'Ask about', 'askmypages' ), /* translators: %s: the visitor's search. */ 'answerButton' => __( 'Answer “%s”', 'askmypages' ), 'greeting' => __( 'Ask a question in your own words. Answers link to the pages they come from.', 'askmypages' ), 'send' => __( 'Send', 'askmypages' ), 'close' => __( 'Close', 'askmypages' ), 'newChat' => __( 'New chat', 'askmypages' ), 'sources' => __( 'Sources', 'askmypages' ), 'subtitle' => __( 'Answers from this site', 'askmypages' ), 'poweredBy' => __( 'Powered by', 'askmypages' ), // The chat window's title, unless the assistant has a name. 'title' => $texts['ask'], ), array_diff_key( $texts, array( 'notFound' => true ) ) ), ); } /** * Loads the widget on the front end when chat is on. */ public static function enqueue() { if ( ! self::enabled() || ! self::visible_to_visitor() ) { return; } wp_register_script( 'askmypages-chat', plugins_url( 'assets/chat.js', __DIR__ ), array(), ASKMYPAGES_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( 'askmypages-chat', 'window.AskMyPagesChat = ' . wp_json_encode( self::config() ) . ';', 'before' ); wp_enqueue_script( 'askmypages-chat' ); } /** * [askmypages_chat scope="category-slug,other-slug"]: chat placed in the * page instead of the floating button. With scope, the AskMyPages service * answers only from posts in those categories on plans that include it. * * @param array|string $atts Attributes. * @return string */ public static function shortcode( $atts ) { if ( ! self::enabled() || ! self::visible_to_visitor() ) { return ''; } $atts = shortcode_atts( array( 'scope' => '' ), $atts, 'askmypages_chat' ); $scope = array_values( array_filter( array_map( 'sanitize_title', explode( ',', (string) $atts['scope'] ) ) ) ); return sprintf( '
', esc_attr( implode( ',', array_slice( $scope, 0, 10 ) ) ) ); } /** * Chat tab on the AskMyPages page, in groups. */ public static function render_settings() { $data = self::refresh() ?? self::data(); $settings = $data['settings'] ?? array(); $usage = $data['usage'] ?? array(); $paid = ! empty( $data['features']['chatCustomisation'] ); $access = self::access(); $look = self::look(); if ( ! empty( $usage['paused'] ) ) { echo '

' . esc_html__( 'AI answers are paused for this month. Search keeps working, and answers resume next month.', 'askmypages' ) . ' ' . wp_kses_post( Admin::upgrade_link( __( 'Get more answers', 'askmypages' ) ) ) . '

'; } elseif ( ! empty( $usage['nearLimit'] ) ) { echo '

' . esc_html__( 'More than 80% of this month\'s AI answers are used. After that, AI answers pause until next month and search keeps working.', 'askmypages' ) . ' ' . wp_kses_post( Admin::upgrade_link( __( 'Get more answers', 'askmypages' ) ) ) . '

'; } $suggestions = array_pad( array_slice( (array) ( $settings['suggestions'] ?? array() ), 0, 4 ), 4, '' ); $wording = self::wording(); ?>

$field ) : ?>