true ) ); wp_add_inline_script( self::ADMIN_SCRIPT_HANDLE, 'window.agentencyChatbotAdmin = ' . wp_json_encode( array( 'confirmDisconnect' => __( 'Disconnect this site? The chatbot disappears from every page and the stored access token is deleted. You can connect again at any time.', 'agentency-chatbot' ), ) ) . ';', 'before' ); } /** * Add the settings page. * * @return void */ public static function register_page() { add_options_page( __( 'Agentency Chatbot', 'agentency-chatbot' ), __( 'Agentency Chatbot', 'agentency-chatbot' ), self::CAPABILITY, self::PAGE_SLUG, array( __CLASS__, 'render_page' ) ); } /** * Nudge an admin who installed the plugin but never connected it. * * Only on the Dashboard and Plugins screens — where someone who just * activated the plugin lands — and never again for an admin who chose * "Don't show this again". The core `is-dismissible` X only hides it for * one page view, which is why the persistent link exists. * * A site that Agentency reported as removed gets a different notice, and * gets it even after "Don't show this again": that choice silenced a * reminder to finish setup, not news that a live chatbot went away. * * @return void */ public static function render_connection_notice() { if ( ! current_user_can( self::CAPABILITY ) || Agentency_Options::is_connected() ) { return; } $screen = get_current_screen(); if ( ! $screen || ! in_array( $screen->id, self::NOTICE_SCREENS, true ) ) { return; } if ( Agentency_Options::was_removed() ) { printf( '

%s %s

', esc_html__( 'Agentency Chatbot: this site was disconnected in your Agentency dashboard, so the chatbot no longer appears on it.', 'agentency-chatbot' ), esc_url( self::page_url() ), esc_html__( 'Reconnect', 'agentency-chatbot' ) ); return; } if ( get_user_meta( get_current_user_id(), self::NOTICE_DISMISSED_META, true ) ) { return; } printf( '

%s %s | %s

', esc_html__( 'Agentency Chatbot is installed but not connected yet.', 'agentency-chatbot' ), esc_url( self::page_url() ), esc_html__( 'Connect your account', 'agentency-chatbot' ), esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=agentency_chatbot_dismiss_notice' ), self::DISMISS_NONCE_ACTION ) ), esc_html__( "Don't show this again", 'agentency-chatbot' ) ); } /** * "Don't show this again": remember the choice for this admin, then go * back to the screen the link was clicked on. * * Capability-gated to the same audience the notice is shown to and * nonce-checked, so a forged link cannot silence the nag for someone else. * * @return void */ public static function handle_dismiss_notice() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'agentency-chatbot' ) ); } check_admin_referer( self::DISMISS_NONCE_ACTION ); update_user_meta( get_current_user_id(), self::NOTICE_DISMISSED_META, 1 ); $referer = wp_get_referer(); wp_safe_redirect( $referer ? $referer : admin_url() ); exit; } // ───────────────────────────────────────────────────────────────────── // POST handling // ───────────────────────────────────────────────────────────────────── /** * Route the form submission to the right action. * * Capability and nonce are checked here, once, and the raw request array * is then handed to the handlers as an ordinary argument. Keeping every * superglobal read inside this one verified scope is what lets the * handlers below be plain functions of their input — easier to reason * about, and provably nonce-guarded rather than guarded somewhere up the * call stack where neither a reviewer nor a static analyser can see it. * * @return void */ public static function handle_post() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'agentency-chatbot' ) ); } check_admin_referer( self::NONCE_ACTION ); $request = wp_unslash( $_POST ); $action = isset( $request['agentency_action'] ) ? sanitize_key( $request['agentency_action'] ) : ''; switch ( $action ) { case 'connect': self::handle_connect( self::read_text( $request, 'agentency_api_key' ), self::read_raw( $request, 'agentency_api_base_url' ) ); break; case 'reconnect': self::handle_reconnect(); break; case 'select_chatbot': self::handle_select_chatbot( absint( self::read_text( $request, 'agentency_chatbot_id' ) ) ); break; case 'save_display': self::handle_save_display( self::sanitize_display_input( $request ), self::read_raw( $request, 'agentency_api_base_url' ) ); break; case 'disconnect': self::handle_disconnect(); break; default: self::redirect_with( 'error', __( 'Unrecognised action.', 'agentency-chatbot' ) ); } } /** * Step 1 — store the API URL and the token, and fetch the agent list. * * The API URL (the connect step's Advanced field) is saved BEFORE the * first call, because that call is what carries the token: a self-hosted * site must never send it to the packaged default first. * * @param string $api_key Sanitized personal access token from the form. * @param string $raw_base_url API URL as typed, '' for the default. * @return void */ private static function handle_connect( $api_key, $raw_base_url ) { if ( '' === $api_key ) { self::redirect_with( 'error', __( 'Paste your Agentency personal access token to continue.', 'agentency-chatbot' ) ); } if ( 0 === strpos( $api_key, self::DEVELOPER_KEY_PREFIX ) ) { self::redirect_with( 'error', __( 'That is an Agentency API key (it starts with agy_), which this plugin cannot use. In Agentency, open Developers → Tokens and create a personal access token with only the "widget:manage" scope instead.', 'agentency-chatbot' ) ); } $base_url = Agentency_API::sanitize_base_url( $raw_base_url ); if ( '' !== $raw_base_url && '' === $base_url ) { self::redirect_with( 'error', self::invalid_api_url_message() ); } Agentency_Options::save_settings( array_merge( Agentency_Options::settings(), array( 'api_base_url' => $base_url ) ) ); self::load_agents( $api_key ); } /** * Reconnect a site Agentency reported as removed, with the stored token. * * @return void */ private static function handle_reconnect() { $api_key = Agentency_Options::api_key(); if ( '' === $api_key ) { self::redirect_with( 'error', __( 'Paste your Agentency personal access token to continue.', 'agentency-chatbot' ) ); } self::load_agents( $api_key ); } /** * Fetch the agents a token may embed, then keep the token and the list * for the picker. * * @param string $api_key Personal access token. * @return void */ private static function load_agents( $api_key ) { $result = Agentency_API::list_chatbots( $api_key ); if ( is_wp_error( $result ) ) { self::redirect_with( 'error', $result->get_error_message() ); } if ( empty( $result['chatbots'] ) ) { self::redirect_with( 'error', __( 'That token works, but the account has no agents yet. Create one in Agentency first.', 'agentency-chatbot' ) ); } // Only persist the token once it has provably worked — a rejected one // should never be left behind for the next page load to retry with. Agentency_Options::save_api_key( $api_key ); set_transient( self::CHATBOTS_TRANSIENT, $result, 10 * MINUTE_IN_SECONDS ); self::redirect_with( 'success', __( 'Connected. Now choose which agent to show on this site.', 'agentency-chatbot' ) ); } /** * The agent list kept for the picker, as * `array{chatbots: array, has_more: bool}`, or null when there is none. * A list cached by 1.3.0 (a bare array of agents) still reads. * * @return array|null */ private static function cached_agents() { $cached = get_transient( self::CHATBOTS_TRANSIENT ); if ( ! is_array( $cached ) ) { return null; } if ( ! isset( $cached['chatbots'] ) ) { $cached = array( 'chatbots' => $cached, 'has_more' => false, ); } if ( ! is_array( $cached['chatbots'] ) || empty( $cached['chatbots'] ) ) { return null; } return array( 'chatbots' => $cached['chatbots'], 'has_more' => ! empty( $cached['has_more'] ), ); } /** * Why a typed API URL was refused. * * @return string */ private static function invalid_api_url_message() { return __( 'The API URL must start with https:// (http:// only for localhost) and must not contain a user name or password.', 'agentency-chatbot' ); } /** * Step 2 — install the chosen agent on this site. * * @param int $chatbot_id Chosen agent id. * @return void */ private static function handle_select_chatbot( $chatbot_id ) { if ( ! $chatbot_id ) { self::redirect_with( 'error', __( 'Choose an agent to continue.', 'agentency-chatbot' ) ); } $result = Agentency_API::install( Agentency_Options::api_key(), $chatbot_id ); if ( is_wp_error( $result ) ) { self::redirect_with( 'error', $result->get_error_message() ); } $installation = isset( $result['installation'] ) && is_array( $result['installation'] ) ? $result['installation'] : array(); $chatbot = isset( $result['chatbot'] ) && is_array( $result['chatbot'] ) ? $result['chatbot'] : array(); Agentency_Options::save_connection( array( 'chatbot_id' => isset( $chatbot['id'] ) ? absint( $chatbot['id'] ) : $chatbot_id, 'chatbot_name' => isset( $chatbot['name'] ) ? sanitize_text_field( $chatbot['name'] ) : '', 'widget_token' => isset( $result['widget_token'] ) ? sanitize_text_field( $result['widget_token'] ) : '', 'widget_url' => isset( $result['widget_url'] ) ? esc_url_raw( $result['widget_url'] ) : '', 'api_base_url' => isset( $result['api_base_url'] ) ? esc_url_raw( $result['api_base_url'] ) : '', 'installation_id' => isset( $installation['id'] ) ? absint( $installation['id'] ) : 0, 'site_host' => isset( $installation['site_host'] ) ? sanitize_text_field( $installation['site_host'] ) : '', 'connected_at' => time(), 'last_heartbeat' => time(), 'heartbeat_interval_hours' => Agentency_Options::sanitize_heartbeat_interval( isset( $result['heartbeat_interval_hours'] ) ? $result['heartbeat_interval_hours'] : null ), ) ); delete_transient( self::CHATBOTS_TRANSIENT ); $warning = ( isset( $chatbot['is_active'] ) && ! $chatbot['is_active'] ) ? ' ' . __( 'Note: this agent is not active yet, so visitors will not be able to chat until you activate it in Agentency.', 'agentency-chatbot' ) : ''; self::redirect_with( 'success', __( 'Your chatbot is now live on this site.', 'agentency-chatbot' ) . $warning ); } /** * Save the display + appearance options. * * A refused API URL keeps the stored one rather than blanking it: a * blank would quietly move a connected self-hosted site, token and all, * to a different origin. * * @param array $input Sanitized display settings. * @param string $raw_base_url API URL as typed. * @return void */ private static function handle_save_display( array $input, $raw_base_url ) { $url_refused = '' !== $raw_base_url && '' === $input['api_base_url']; if ( $url_refused ) { $input['api_base_url'] = Agentency_Options::setting( 'api_base_url' ); } Agentency_Options::save_settings( array_merge( Agentency_Options::settings(), $input ) ); if ( $url_refused ) { self::redirect_with( 'error', sprintf( /* translators: %s: why the API URL was refused. */ __( 'Settings saved, except the API URL, which was left unchanged. %s', 'agentency-chatbot' ), self::invalid_api_url_message() ) ); } self::redirect_with( 'success', __( 'Settings saved.', 'agentency-chatbot' ) ); } /** * Reduce the submitted form to a validated settings array. * * Takes the request array as an argument rather than reading `$_POST`, so * it stays a pure function that the nonce check in {@see handle_post()} * already guards. * * @param array $request Unslashed request data. * @return array */ private static function sanitize_display_input( array $request ) { $excluded = self::read_text( $request, 'agentency_excluded_post_ids' ); $ids = array_filter( array_map( 'absint', array_map( 'trim', explode( ',', $excluded ) ) ) ); return array( 'enabled' => isset( $request['agentency_enabled'] ) ? 1 : 0, 'identify_users' => isset( $request['agentency_identify_users'] ) ? 1 : 0, 'hide_launcher' => isset( $request['agentency_hide_launcher'] ) ? 1 : 0, 'page_context' => isset( $request['agentency_page_context'] ) ? 1 : 0, 'position' => self::read_choice( $request, 'agentency_position', array( 'bottom-right', 'bottom-left', 'top-right', 'top-left' ) ), 'launcher_size' => self::read_choice( $request, 'agentency_launcher_size', array( 'small', 'medium', 'large' ) ), 'offset_x' => self::read_bounded_int( $request, 'agentency_offset_x', 0, 200 ), 'offset_y' => self::read_bounded_int( $request, 'agentency_offset_y', 0, 200 ), 'auto_open_delay' => self::read_bounded_int( $request, 'agentency_auto_open_delay', 0, 60 ), 'language' => Agentency_Options::sanitize_language( self::read_text( $request, 'agentency_language' ) ), 'excluded_post_ids' => implode( ',', $ids ), 'api_base_url' => self::read_url( $request, 'agentency_api_base_url' ), ); } /** * Disconnect: release the allowlist entry server-side, then forget both * the connection and the credential locally. * * A server-side failure must not strand the merchant — the local state is * cleared either way, and the message says what happened. * * @return void */ private static function handle_disconnect() { $connection = Agentency_Options::connection(); $message = __( 'Disconnected. The chatbot no longer appears on this site.', 'agentency-chatbot' ); if ( $connection['installation_id'] ) { $result = Agentency_API::uninstall( Agentency_Options::api_key(), $connection['installation_id'] ); if ( is_wp_error( $result ) ) { $message = __( 'Removed from this site. Agentency could not be reached, so you may also want to remove this site in your Agentency dashboard.', 'agentency-chatbot' ); } } Agentency_Options::forget(); delete_transient( self::CHATBOTS_TRANSIENT ); self::redirect_with( 'success', $message ); } // ───────────────────────────────────────────────────────────────────── // Heartbeat // ───────────────────────────────────────────────────────────────────── /** * `cron_schedules` filter: add the heartbeat recurrence at the stored, * server-sent cadence. * * @param mixed $schedules Schedules registered so far. * @return array */ public static function register_heartbeat_schedule( $schedules ) { if ( ! is_array( $schedules ) ) { $schedules = array(); } $schedules[ self::HEARTBEAT_SCHEDULE ] = array( 'interval' => self::heartbeat_interval_seconds(), 'display' => __( 'Agentency check-in', 'agentency-chatbot' ), ); return $schedules; } /** * The recurrence the heartbeat should run on. * * @return string */ public static function heartbeat_recurrence() { return Agentency_Options::HEARTBEAT_INTERVAL_DEFAULT_HOURS === Agentency_Options::heartbeat_interval_hours() ? 'daily' : self::HEARTBEAT_SCHEDULE; } /** * The heartbeat cadence in seconds. * * @return int */ private static function heartbeat_interval_seconds() { return Agentency_Options::heartbeat_interval_hours() * HOUR_IN_SECONDS; } /** * Ensure the heartbeat event exists while connected, at the cadence the * API last sent (`heartbeat_interval_hours` on install and on every * heartbeat), and move it when that cadence changes. * * A replaced event keeps its pending run when that is sooner than one new * interval from now, so a shorter cadence takes effect at once and a * longer one never postpones a ping that was already due. * * @return void */ public static function schedule_heartbeat() { if ( ! Agentency_Options::is_connected() ) { wp_clear_scheduled_hook( self::HEARTBEAT_HOOK ); return; } $recurrence = self::heartbeat_recurrence(); $seconds = self::heartbeat_interval_seconds(); $event = wp_get_scheduled_event( self::HEARTBEAT_HOOK ); $next = time() + HOUR_IN_SECONDS; if ( $event ) { $current_interval = isset( $event->interval ) ? (int) $event->interval : 0; if ( $recurrence === $event->schedule && $seconds === $current_interval ) { return; } $next = min( (int) $event->timestamp, time() + $seconds ); wp_clear_scheduled_hook( self::HEARTBEAT_HOOK ); } wp_schedule_event( $next, $recurrence, self::HEARTBEAT_HOOK ); } /** * Report liveness + version to Agentency, and adopt the cadence the reply * carries. * * A failure never surfaces on the public site — a heartbeat is telemetry, * and a temporarily unreachable API must not become a visitor-facing * error. It is recorded, though (`last_heartbeat_failed_at`), so the * settings screen can tell the admin why "Last check-in" stopped moving * instead of leaving them to guess. The next success clears it. * * A reply whose `data.installation.status` is `removed` means someone * disconnected this site from the Agentency dashboard. The widget would * keep loading here until then (the server no longer allowlists the * host, but the page still prints the tag), so the live state is cleared * and the admin screens offer a reconnect. Anything other than exactly * `removed` — including a reply from an API that does not send the * status — leaves the connection alone. * * @return void */ public static function run_heartbeat() { $connection = Agentency_Options::connection(); if ( ! $connection['installation_id'] || '' === Agentency_Options::api_key() ) { return; } Agentency_Options::encrypt_stored_api_key(); $result = Agentency_API::heartbeat( Agentency_Options::api_key(), $connection['installation_id'] ); if ( is_wp_error( $result ) ) { $connection['last_heartbeat_failed_at'] = time(); Agentency_Options::save_connection( $connection ); return; } $data = isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array(); if ( isset( $data['installation']['status'] ) && 'removed' === $data['installation']['status'] ) { Agentency_Options::mark_removed(); delete_transient( self::CHATBOTS_TRANSIENT ); self::schedule_heartbeat(); return; } $previous_hours = Agentency_Options::heartbeat_interval_hours(); $hours = Agentency_Options::sanitize_heartbeat_interval( isset( $data['heartbeat_interval_hours'] ) ? $data['heartbeat_interval_hours'] : null ); $connection['last_heartbeat'] = time(); $connection['last_heartbeat_failed_at'] = 0; if ( $hours > 0 ) { $connection['heartbeat_interval_hours'] = $hours; } Agentency_Options::save_connection( $connection ); // WP-Cron has already queued this event's next run on the old // recurrence by the time the callback fires, so move that one. if ( Agentency_Options::heartbeat_interval_hours() !== $previous_hours ) { self::schedule_heartbeat(); } } // ───────────────────────────────────────────────────────────────────── // Site Health // ───────────────────────────────────────────────────────────────────── /** * `debug_information` filter: an "Agentency Chatbot" section on Tools → * Site Health → Info. * * That tab is what a merchant copies into a support ticket, so it carries * what support asks first — version, connection, agent, registered host, * API origin, check-in cadence and history, how the token is stored. * Read-only, and deliberately free of secrets: no access token, not even * its masked hint, and no widget token, because the copied report is * pasted into third-party channels. * * @param mixed $info Sections registered so far. * @return array */ public static function site_health_info( $info ) { if ( ! is_array( $info ) ) { $info = array(); } $connection = Agentency_Options::connection(); $connected = Agentency_Options::is_connected(); $removed = Agentency_Options::was_removed(); $failed_at = Agentency_Options::unresolved_heartbeat_failure(); $last = is_numeric( $connection['last_heartbeat'] ) ? (int) $connection['last_heartbeat'] : 0; $storage = Agentency_Options::api_key_storage(); $connected_value = __( 'No', 'agentency-chatbot' ); if ( $connected ) { $connected_value = __( 'Yes', 'agentency-chatbot' ); } elseif ( $removed ) { $connected_value = __( 'No — disconnected in the Agentency dashboard', 'agentency-chatbot' ); } $storage_values = array( Agentency_Options::API_KEY_STORAGE_NONE => __( 'None stored', 'agentency-chatbot' ), Agentency_Options::API_KEY_STORAGE_ENCRYPTED => __( 'Encrypted', 'agentency-chatbot' ), Agentency_Options::API_KEY_STORAGE_PLAINTEXT => __( 'Not encrypted', 'agentency-chatbot' ), Agentency_Options::API_KEY_STORAGE_UNREADABLE => __( 'Cannot be read — connect again', 'agentency-chatbot' ), ); $fields = array( 'version' => array( 'label' => __( 'Plugin version', 'agentency-chatbot' ), 'value' => AGENTENCY_CHATBOT_VERSION, ), 'connected' => array( 'label' => __( 'Connected', 'agentency-chatbot' ), 'value' => $connected_value, 'debug' => $connected ? 'true' : ( $removed ? 'removed' : 'false' ), ), 'agent' => array( 'label' => __( 'Agent', 'agentency-chatbot' ), 'value' => $connected ? $connection['chatbot_name'] : __( 'Not connected', 'agentency-chatbot' ), ), 'site_host' => array( 'label' => __( 'Registered host', 'agentency-chatbot' ), 'value' => $connected ? $connection['site_host'] : __( 'Not connected', 'agentency-chatbot' ), ), 'api_origin' => array( 'label' => __( 'API URL', 'agentency-chatbot' ), 'value' => Agentency_API::base_url(), ), 'interval_hours' => array( 'label' => __( 'Check-in interval (hours)', 'agentency-chatbot' ), 'value' => Agentency_Options::heartbeat_interval_hours(), ), 'last_heartbeat' => array( 'label' => __( 'Last check-in', 'agentency-chatbot' ), 'value' => self::last_check_in_text( $connection ), 'debug' => $last ? gmdate( 'c', $last ) : 'never', ), 'token_storage' => array( 'label' => __( 'Access token storage', 'agentency-chatbot' ), 'value' => $storage_values[ $storage ], 'debug' => $storage, ), ); if ( $failed_at ) { $fields['last_heartbeat_failed_at'] = array( 'label' => __( 'Last failed check-in', 'agentency-chatbot' ), 'value' => sprintf( /* translators: %s: human-readable time difference, for example "5 hours". */ __( '%s ago', 'agentency-chatbot' ), human_time_diff( $failed_at ) ), 'debug' => gmdate( 'c', $failed_at ), ); } $info['agentency-chatbot'] = array( 'label' => __( 'Agentency Chatbot', 'agentency-chatbot' ), 'fields' => $fields, ); return $info; } /** * `site_status_tests` filter: register the token-storage check on * Tools → Site Health → Status. * * @param mixed $tests Tests registered so far. * @return array */ public static function site_status_tests( $tests ) { if ( ! is_array( $tests ) ) { $tests = array(); } $tests['direct'][ self::TOKEN_STORAGE_TEST ] = array( 'label' => __( 'Agentency Chatbot access token storage', 'agentency-chatbot' ), 'test' => array( __CLASS__, 'token_storage_test' ), ); return $tests; } /** * The token-storage check: good when nothing is stored or the token is * sealed; a recommendation when it sits in the database as plain text * or can no longer be opened. * * @return array Core's direct-test result shape. */ public static function token_storage_test() { $result = array( 'label' => __( 'The Agentency access token is stored encrypted', 'agentency-chatbot' ), 'status' => 'good', 'badge' => array( 'label' => __( 'Security', 'agentency-chatbot' ), 'color' => 'blue', ), 'description' => '

' . esc_html__( 'Agentency Chatbot encrypts its personal access token with a key derived from the secret keys in wp-config.php, so a copy of the database alone does not reveal it.', 'agentency-chatbot' ) . '

', 'actions' => '', 'test' => self::TOKEN_STORAGE_TEST, ); $storage = Agentency_Options::api_key_storage(); if ( Agentency_Options::API_KEY_STORAGE_PLAINTEXT === $storage ) { $result['label'] = __( 'The Agentency access token is stored unencrypted', 'agentency-chatbot' ); $result['status'] = 'recommended'; $result['description'] = '

' . esc_html__( 'The personal access token is stored as plain text in the database, because this site has no sodium encryption library or has not opened the plugin settings since updating (opening them encrypts it). The token can only manage the chat widget, but treat database copies with care.', 'agentency-chatbot' ) . '

'; } elseif ( Agentency_Options::API_KEY_STORAGE_UNREADABLE === $storage ) { $result['label'] = __( 'The Agentency access token can no longer be read', 'agentency-chatbot' ); $result['status'] = 'recommended'; $result['description'] = '

' . esc_html__( 'The stored token was encrypted with secret keys this site no longer has (the keys in wp-config.php changed). Check-ins have stopped; disconnect and connect again with a new token.', 'agentency-chatbot' ) . '

'; $result['actions'] = sprintf( '

%s

', esc_url( self::page_url() ), esc_html__( 'Open Agentency Chatbot settings', 'agentency-chatbot' ) ); } return $result; } // ───────────────────────────────────────────────────────────────────── // Rendering // ───────────────────────────────────────────────────────────────────── /** * Render the settings page. * * @return void */ public static function render_page() { if ( ! current_user_can( self::CAPABILITY ) ) { return; } Agentency_Options::encrypt_stored_api_key(); $connection = Agentency_Options::connection(); $has_key = '' !== Agentency_Options::api_key(); $connected = Agentency_Options::is_connected(); $removed = Agentency_Options::was_removed(); $agents = self::cached_agents(); echo '
'; printf( '

%s

', esc_html__( 'Agentency Chatbot', 'agentency-chatbot' ) ); self::render_notice(); self::render_api_url_warning(); if ( $connected ) { self::render_connected_panel( $connection ); self::render_display_form(); } elseif ( $has_key && null !== $agents ) { self::render_chatbot_picker( $agents['chatbots'], $agents['has_more'] ); } elseif ( $removed && $has_key ) { self::render_removed_panel( $connection ); } else { if ( $removed ) { self::render_removed_notice( $connection ); } self::render_connect_form(); } echo '
'; } /** * Say so when the access token goes anywhere but Agentency's own API. * * A custom API URL is legitimate for a self-hosted instance, and a way to * siphon the token off for anyone who once had admin access — so it is * never silent. A stored URL the policy refuses gets an error instead: * every call is blocked until it is fixed. * * @return void */ private static function render_api_url_warning() { $base_url = Agentency_API::base_url(); if ( Agentency_API::DEFAULT_BASE_URL === $base_url ) { return; } if ( '' === $base_url ) { printf( '

%s

', esc_html__( 'The API URL in these settings is not allowed. It must start with https:// (http:// only for localhost). Correct it or clear it to use Agentency.', 'agentency-chatbot' ) ); return; } printf( '

%s

', wp_kses( sprintf( /* translators: %s: the custom API origin, wrapped in . */ __( 'This site sends its Agentency access token to %s instead of Agentency\'s own API. Keep this only if you run your own Agentency instance.', 'agentency-chatbot' ), '' . esc_html( $base_url ) . '' ), array( 'code' => array() ) ) ); } /** * The "removed in Agentency" explanation. * * @param array $connection Stored connection. * @return void */ private static function render_removed_notice( array $connection ) { $host = is_string( $connection['site_host'] ) ? $connection['site_host'] : ''; ?>

. */ __( 'This site (%s) was disconnected in your Agentency dashboard, so the chatbot no longer appears on it.', 'agentency-chatbot' ), '' . esc_html( $host ) . '' ), array( 'code' => array() ) ); } else { esc_html_e( 'This site was disconnected in your Agentency dashboard, so the chatbot no longer appears on it.', 'agentency-chatbot' ); } ?>

%s

', isset( $flash['status'] ) && 'error' === $flash['status'] ? 'error' : 'success', esc_html( $flash['message'] ) ); } /** * Step 1 form — paste a personal access token. * * On the hosted service the page links straight to the screen where the * token is created, so a first-time merchant does not have to hunt for * it. A custom API URL means a self-hosted or staging instance with its * own dashboard address, which this plugin cannot know — no link beats a * wrong one there. * * The API URL lives here too, under Advanced, because the Connect click * is the first request that carries the token: setting it only after * connecting would send a self-hosted token to Agentency first. * * `autocomplete="new-password"` rather than `off`, which browsers ignore * on password fields: it stops a saved WordPress password being filled in * as the token. * * @return void */ private static function render_connect_form() { $api_base_url = Agentency_Options::setting( 'api_base_url' ); $api_base_url = is_string( $api_base_url ) ? $api_base_url : ''; ?>

>

' . esc_html( $connection['chatbot_name'] ) . '', '' . esc_html( $connection['site_host'] ) . '' ); ?>

>

*/ public static function language_choices() { $native = array( 'en' => 'English', 'ar' => 'العربية', 'es' => 'Español', 'fr' => 'Français', 'de' => 'Deutsch', 'pt' => 'Português', 'hi' => 'हिन्दी', 'bn' => 'বাংলা', 'ur' => 'اردو', 'ja' => '日本語', 'id' => 'Bahasa Indonesia', 'tr' => 'Türkçe', 'ru' => 'Русский', 'ko' => '한국어', 'vi' => 'Tiếng Việt', ); $choices = array( Agentency_Options::LANGUAGE_DEFAULT => __( "Follow the agent's language (default)", 'agentency-chatbot' ), ); foreach ( Agentency_Options::LANGUAGE_CODES as $code ) { $label = isset( $native[ $code ] ) ? $native[ $code ] : $code; $choices[ $code ] = $label . ' · ' . $code; } return $choices; } /** * A sanitized scalar field from the request array. * * @param array $request Unslashed request data. * @param string $field Field name. * @return string */ private static function read_text( array $request, $field ) { if ( ! isset( $request[ $field ] ) || ! is_scalar( $request[ $field ] ) ) { return ''; } return trim( sanitize_text_field( (string) $request[ $field ] ) ); } /** * A scalar field exactly as typed, trimmed, or ''. Only for values a * dedicated sanitizer reduces next ({@see read_url()}), or that are only * compared with '' to tell "left blank" from "refused". * * @param array $request Unslashed request data. * @param string $field Field name. * @return string */ private static function read_raw( array $request, $field ) { if ( ! isset( $request[ $field ] ) || ! is_scalar( $request[ $field ] ) ) { return ''; } return trim( (string) $request[ $field ] ); } /** * An API URL field reduced to an origin the token may be sent to, or '' * when blank or refused ({@see Agentency_API::sanitize_base_url()}). * * @param array $request Unslashed request data. * @param string $field Field name. * @return string */ private static function read_url( array $request, $field ) { return Agentency_API::sanitize_base_url( self::read_raw( $request, $field ) ); } /** * A value constrained to a known set, or '' meaning "inherit the * dashboard default". * * @param array $request Unslashed request data. * @param string $field Field name. * @param array $allowed Allowed values. * @return string */ private static function read_choice( array $request, $field, array $allowed ) { $value = self::read_text( $request, $field ); return in_array( $value, $allowed, true ) ? $value : ''; } /** * An integer clamped to a range, or '' when blank. * * @param array $request Unslashed request data. * @param string $field Field name. * @param int $min Minimum. * @param int $max Maximum. * @return string */ private static function read_bounded_int( array $request, $field, $min, $max ) { $raw = self::read_text( $request, $field ); if ( '' === $raw ) { return ''; } return (string) max( $min, min( $max, (int) $raw ) ); } /** * Redirect back to the settings page with a flash message. * * The message travels in a short-lived per-user transient, not the query * string: a URL-borne message could be forged by anyone who can get an * admin to click a link, and it reappeared on every reload. A minute is * ample for one redirect; core's expired-transient cleanup removes any * flash that is never displayed. * * @param string $status 'success' or 'error'. * @param string $message Human-readable message. * @return void */ private static function redirect_with( $status, $message ) { set_transient( self::flash_key(), array( 'status' => 'error' === $status ? 'error' : 'success', 'message' => (string) $message, ), MINUTE_IN_SECONDS ); wp_safe_redirect( self::page_url() ); exit; } }