settings = $settings; } /** * Adds the menu. * * @return void */ public function add_menu() { /* * The menu icon is the brand mark, not a Dashicon. * * It is the white cut-out (`assets/images/mybot-menu-icon.png`, 20x20 on transparency, built * by tools/make-plugin-logo.mjs), because WordPress paints a top-level menu icon over its * dark sidebar at 60% opacity — the full-colour logo on white would arrive there as an * opaque white square. Both facts are measured, not assumed; see docs/08 muc 3.36. */ add_menu_page( __( 'AI House', 'aihouse' ), __( 'AI House', 'aihouse' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_page' ), MYBOT_PLUGIN_URL . 'assets/images/mybot-menu-icon.png', 57 ); /* * WordPress builds the link of a top-level menu item from the FIRST entry of its * submenu: _wp_menu_output() in wp-admin/menu-header.php reads $submenu_items[0][2] * after array_values(). The knowledge post type adds its own submenu under this * parent through _add_post_type_submenus(), and that runs BEFORE this callback -- so * "AI House Knowledge" becomes entry zero, and clicking "AI House" opens the knowledge * list instead of the dashboard. * * Registering the dashboard here is not enough: appending it only makes it entry * one. The order is corrected in prioritise_dashboard_submenu() below, which runs * after every entry exists. */ add_submenu_page( self::PAGE_SLUG, __( 'AI House', 'aihouse' ), __( 'Dashboard', 'aihouse' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_page' ) ); /* * The requests screen sits beside Settings rather than inside it. It is also the screen the * Status card and the hand-over line now point at, so the sentence "the data is in * WordPress" ends with somewhere to click. */ add_submenu_page( self::PAGE_SLUG, __( 'Questions for a human', 'aihouse' ), __( 'Requests', 'aihouse' ), 'manage_options', self::REQUESTS_SLUG, array( $this, 'render_requests_page' ) ); /* * The visitor cards (1.17.0), beside Requests because they are the same kind of thing: a list of * people a human may need to look at. Requests answers "who is waiting for an answer"; this answers * "who gave us their number". */ add_submenu_page( self::PAGE_SLUG, __( 'Customers who left their details', 'aihouse' ), __( 'Customers', 'aihouse' ), 'manage_options', self::CONTACTS_SLUG, array( $this, 'render_contacts_page' ) ); /* * There is no add-ons submenu any more (1.11.0). * * The screen it opened answered exactly one question — "what is installed, and which features * are switched on?" — which is a setting, and it was two menus away from every other setting. * Its list, its refusals and the new key control are now the `addons` SECTION of Settings; the * reasons the screen existed are unchanged, and the file that draws it is unchanged too. * * Nothing is left pointing at the old URL: the slug constant is gone from * `mybot-constants.php` as well, instead of becoming a name with no screen behind it. */ add_action( 'admin_menu', array( $this, 'prioritise_dashboard_submenu' ), PHP_INT_MAX ); } /** * Renders the screen that manages questions left for a human. * * @return void */ public function render_requests_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'aihouse' ) ); } $plugin = mybot(); require MYBOT_PLUGIN_DIR . 'admin/partials/mybot-admin-requests.php'; } /** * Renders the screen that lists the visitors who left their details (1.17.0). * * @return void */ public function render_contacts_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'aihouse' ) ); } $plugin = mybot(); require MYBOT_PLUGIN_DIR . 'admin/partials/mybot-admin-contacts.php'; } /** * Downloads the visitor cards as CSV (1.17.0). * * WHY `admin-post.php` AND NOT A REST ROUTE * * Because a download is a LINK. A person looking at a list of customers expects to click "Download CSV" * and get a file; routing that through `fetch()` means holding the bytes in JavaScript and building an * object URL, which some browsers block, some proxies mangle, and which cannot be bookmarked or opened in * a new tab. `admin-post.php` is the WordPress door built for exactly this: it runs before any theme * output, it is protected by a nonce, and the browser does the saving. * * ⚠️ **Excel is the reason for both details below.** The file starts with a **UTF-8 BOM**, without which * Excel on Windows reads Vietnamese names as mojibake (the same lesson as docs/08 muc 6.16b, one layer * out), and every cell that begins with `=`, `+`, `-` or `@` is prefixed with an apostrophe, because * otherwise a note a visitor typed becomes a **formula** that Excel happily runs on the computer of the * person who exported it. Visitor text is untrusted input, and it stays untrusted after it is written to * a spreadsheet. * * @return void */ public function handle_contacts_export() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aihouse' ) ); } check_admin_referer( 'mybot_contacts_export' ); $rows = MYBOT_Contacts::recent( 200, 0 ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="mybot-customers-' . gmdate( 'Y-m-d' ) . '.csv"' ); $out = fopen( 'php://output', 'w' ); if ( false === $out ) { exit; } // The BOM: see the note above. Written before anything else, because Excel reads it first. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv fputcsv( $out, array( __( 'Name', 'aihouse' ), __( 'Phone number', 'aihouse' ), __( 'Email address', 'aihouse' ), __( 'Preferred channel', 'aihouse' ), __( 'Note', 'aihouse' ), __( 'Language', 'aihouse' ), __( 'Agreed to be contacted', 'aihouse' ), __( 'Date', 'aihouse' ), ) ); foreach ( $rows as $row ) { fputcsv( $out, array( self::csv_cell( (string) $row['name'] ), self::csv_cell( (string) $row['phone'] ), self::csv_cell( (string) $row['email'] ), self::csv_cell( (string) $row['channel'] ), self::csv_cell( (string) $row['note'] ), self::csv_cell( (string) $row['lang'] ), empty( $row['consent'] ) ? __( 'No', 'aihouse' ) : __( 'Yes', 'aihouse' ), self::csv_cell( (string) $row['created_at'] ), ) ); } /* * `php://output`, not the filesystem: WP_Filesystem writes FILES, and this stream IS the response the * browser is already reading. The three calls below are the reason this method carries `phpcs:ignore` * comments instead of using an abstraction that does not fit. */ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose fclose( $out ); exit; } /** * Deletes one visitor card (1.17.0). * * A POST with a nonce, never a link: deleting a person's details is not something a crawler, a prefetch * or an accidental middle-click should be able to do. * * @return void */ public function handle_contacts_delete() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aihouse' ) ); } $id = isset( $_POST['id'] ) ? absint( wp_unslash( (string) $_POST['id'] ) ) : 0; check_admin_referer( 'mybot_contacts_delete_' . $id ); $deleted = $id > 0 && MYBOT_Contacts::delete( $id ); wp_safe_redirect( add_query_arg( array( 'page' => self::CONTACTS_SLUG, 'mybot_msg' => $deleted ? 'deleted' : 'notfound', ), admin_url( 'admin.php' ) ) ); exit; } /** * One CSV cell, made safe for a spreadsheet. * * @param string $value Raw value. * @return string */ protected static function csv_cell( $value ) { $value = (string) $value; if ( '' === $value ) { return ''; } return preg_match( '/^[=+\-@\t\r]/', $value ) ? "'" . $value : $value; } /** * The logo, the plugin name and an optional lead line — the top of every AI House screen. * * One helper instead of a copy per screen: the Settings and Requests screens must not drift * apart, and the whole point of the logo is that the brand looks the same everywhere it appears. * * @param string $title Screen title. * @param string $lead Optional sentence under the title. * @return void */ public static function brand_header( $title, $lead = '' ) { ?>

$item ) { if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) { $dashboard[ $key ] = $item; } else { $rest[ $key ] = $item; } } if ( empty( $dashboard ) ) { return; } // Union keeps the keys, so array_values() in menu-header.php sees the dashboard first. $submenu[ self::PAGE_SLUG ] = $dashboard + $rest; } /** * Registers the settings. * * @return void */ public function register_settings() { register_setting( 'mybot_settings_group', MYBOT_OPTION_KEY, array( 'type' => 'array', 'sanitize_callback' => array( $this->settings, 'save' ), 'default' => MYBOT_Settings::defaults(), ) ); } /** * Enqueues the CSS and JS for the settings page. * * @param string $hook_suffix The current screen. * @return void */ public function enqueue_assets( $hook_suffix ) { /* * The dashboard and the requests screen share one stylesheet and one script: the support * buttons on the requests screen are bound by the same handler that binds them anywhere else * (`[data-mybot-support]`), and splitting the bundle would be two places to keep in step. */ $dashboard_hook = 'toplevel_page_' . self::PAGE_SLUG; $requests_hook = self::PAGE_SLUG . '_page_' . self::REQUESTS_SLUG; $is_dashboard = $dashboard_hook === $hook_suffix; $is_requests = $requests_hook === $hook_suffix; if ( ! $is_dashboard && ! $is_requests ) { return; } wp_enqueue_style( 'mybot-admin', MYBOT_PLUGIN_URL . 'admin/css/mybot-admin.css', array(), MYBOT_VERSION, 'all' ); // The React build is optional: it is only loaded when it already exists. $react_build = MYBOT_PLUGIN_DIR . 'admin/build/index.js'; $has_react = $is_dashboard && file_exists( $react_build ); // Enqueue the admin script first so window.mybotAdmin exists when the React bundle runs. wp_enqueue_script( 'mybot-admin', MYBOT_PLUGIN_URL . 'admin/js/mybot-admin.js', array(), MYBOT_VERSION, true ); wp_localize_script( 'mybot-admin', 'mybotAdmin', array( 'restNamespace' => MYBOT_REST_NAMESPACE, 'restUrl' => esc_url_raw( rest_url( MYBOT_REST_NAMESPACE ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'hasReact' => $has_react, 'progress' => mybot()->indexer->get_progress(), 'queue' => mybot()->queue->stats(), 'maxUpload' => MYBOT_Knowledge_Importer::MAX_BYTES, 'maxEntries' => MYBOT_Knowledge_Importer::MAX_ENTRIES, 'templates' => array( array( 'label' => 'Markdown (.md)', 'url' => MYBOT_PLUGIN_URL . 'assets/templates/knowledge-template.md', ), array( 'label' => 'CSV (.csv)', 'url' => MYBOT_PLUGIN_URL . 'assets/templates/knowledge-template.csv', ), array( 'label' => 'JSON (.json)', 'url' => MYBOT_PLUGIN_URL . 'assets/templates/knowledge-template.json', ), ), 'strings' => array( 'working' => __( 'Working...', 'aihouse' ), 'done' => __( 'Done', 'aihouse' ), 'failed' => __( 'Failed', 'aihouse' ), 'confirm' => __( 'Are you sure you want to do this?', 'aihouse' ), 'resetHint' => __( 'Clears the indexing fingerprint for all content. The next run processes everything again.', 'aihouse' ), /* translators: %s: number of jobs still waiting in the background queue */ 'queuePending' => __( 'Waiting in queue: %s', 'aihouse' ), /* translators: %s: number of items skipped because their content did not change */ 'queueSkipped' => __( '%s item(s) unchanged, so they were not indexed again.', 'aihouse' ), 'queueNoneSkipped' => __( 'Every item needed indexing.', 'aihouse' ), 'indexStarting' => __( 'Starting knowledge indexing...', 'aihouse' ), 'testing' => __( 'Testing the connection...', 'aihouse' ), 'modelsLoading' => __( 'Loading the free model list...', 'aihouse' ), 'modelsLoadingPaid' => __( 'Loading the paid model list...', 'aihouse' ), /* translators: %s: how much free quota is left for this provider */ 'budget' => __( 'Free quota: %s', 'aihouse' ), /* translators: %s: number of free models returned by the provider */ 'modelsLoadedFree' => __( 'Loaded %s free models. Pick one from the list below.', 'aihouse' ), /* translators: %s: number of paid models returned by the provider */ 'modelsLoadedPaid' => __( 'Loaded %s paid models. Switch to Load free models for the free ones.', 'aihouse' ), /* translators: %s: number of models returned by the provider */ 'modelsLoadedAll' => __( 'Loaded %s models. Pick one from the list below.', 'aihouse' ), 'loadModels' => __( 'Load models', 'aihouse' ), 'modelsLoadingAll' => __( 'Loading the model list...', 'aihouse' ), 'keyChecking' => __( 'Checking the API key...', 'aihouse' ), 'modelChecking' => __( 'Trying this model with a real message...', 'aihouse' ), /* translators: %s: number of models returned after the key was accepted */ 'keyAcceptedSaved' => __( 'Key accepted and saved. Loaded %s models — pick one, then press Save.', 'aihouse' ), /* translators: %s: number of models returned after the key was accepted */ 'keyAccepted' => __( 'Key accepted. Loaded %s models — pick one, then press Save.', 'aihouse' ), /* translators: %s: the error message returned by the provider */ 'keyRejected' => __( 'The key check did not succeed: %s', 'aihouse' ), 'modelsEmpty' => __( 'No models came back this time. Paste a model ID from openrouter.ai/models instead.', 'aihouse' ), 'unsavedWarning' => __( 'You have unsaved changes. Press Save first: Test connection reads the settings stored in the database, not the form.', 'aihouse' ), /* translators: %s: the provider and model currently saved in the database */ 'settingsInUse' => __( 'Settings in use: %s', 'aihouse' ), /* translators: %s: the job ID that failed */ 'failedJob' => __( 'Failed job #%s', 'aihouse' ), 'lookupEmpty' => __( 'Type a question to look up first.', 'aihouse' ), 'lookupRunning' => __( 'Searching the knowledge base...', 'aihouse' ), /* translators: %s: name of the knowledge item the answer was found in */ 'lookupSource' => __( 'Source: %s', 'aihouse' ), /* translators: %s: the excerpt from the knowledge base that matched best */ 'nearestChunk' => __( 'Closest chunk: %s', 'aihouse' ), /* translators: %s: similarity score, from 0 to 1 */ 'roundtrip' => __( 'A stored chunk matched itself at %s', 'aihouse' ), 'llmDown' => __( 'While this line is red the chat answers every question with the fallback message, so no citation can appear yet: fix the model or the key first, then look at RAG.', 'aihouse' ), /* translators: %s: when the last lookup ran and the score it returned */ 'lastLookup' => __( 'Last knowledge lookup: %s', 'aihouse' ), /* translators: %s: whether the Supabase log mirror is on, off or failing */ 'logMirror' => __( 'Supabase log mirror: %s', 'aihouse' ), /* translators: 1: similarity score, 2: how long ago the vector was written */ 'selfCheck' => __( 'Vector check: %1$s (from %2$s ago)', 'aihouse' ), 'ageUnknown' => __( 'an unknown time', 'aihouse' ), /* * The Get started lights. Each one is a WORD as well as a colour, because a colour alone is * not a status for a reader who cannot separate red from green — the stylesheet prefixes a * tick, a dot or a bang, and this text is what a screen reader announces. */ 'stepConnected' => __( 'Connected', 'aihouse' ), 'stepNotConnected' => __( 'No answer', 'aihouse' ), 'stepWorking' => __( 'Working…', 'aihouse' ), 'stepDone' => __( 'Indexed', 'aihouse' ), 'stepReady' => __( 'Ready', 'aihouse' ), 'stepNothingToDo' => __( 'Nothing queued', 'aihouse' ), 'stepFound' => __( 'Found it', 'aihouse' ), 'stepNotFound' => __( 'Not found', 'aihouse' ), /* translators: %s: number of jobs waiting in the queue */ 'stepQueued' => __( 'The queue is still working: %s job(s) waiting.', 'aihouse' ), 'stepIndexed' => __( 'Your content is indexed, so the assistant can find it.', 'aihouse' ), 'stepVerifyNote' => __( 'Ready to search. This is the check that matters: it proves the knowledge base can find your own words.', 'aihouse' ), 'supportPathRunning' => __( 'Running the visitor path for the hand-over form...', 'aihouse' ), /* translators: 1: name of the step, 2: what happened at that step */ 'supportPathStep' => __( 'Hand-over path · %1$s: %2$s', 'aihouse' ), ), ) ); // The React bundle depends on the script above, so window.mybotAdmin is already available. if ( $has_react ) { wp_enqueue_style( 'mybot-admin-app', MYBOT_PLUGIN_URL . 'admin/css/mybot-app.css', array(), MYBOT_VERSION, 'all' ); $asset_file = MYBOT_PLUGIN_DIR . 'admin/build/index.asset.php'; $asset = file_exists( $asset_file ) ? require $asset_file : array( 'dependencies' => array( 'wp-element', 'wp-api-fetch', 'wp-i18n' ), 'version' => MYBOT_VERSION, ); $dependencies = isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : array(); // Make sure the admin script runs first so window.mybotAdmin is present. $dependencies[] = 'mybot-admin'; /* * webpack marks react and react-dom as external dependencies. WordPress does * register both handles, but when an installation is missing them they are * mapped to wp-element so the bundle never fails on a missing global React. */ foreach ( array( 'react', 'react-dom' ) as $vendor_handle ) { if ( ! wp_script_is( $vendor_handle, 'registered' ) ) { wp_register_script( $vendor_handle, false, array( 'wp-element' ), MYBOT_VERSION, true ); } } wp_enqueue_script( 'mybot-admin-react', MYBOT_PLUGIN_URL . 'admin/build/index.js', array_unique( $dependencies ), (string) ( isset( $asset['version'] ) ? $asset['version'] : MYBOT_VERSION ), true ); /* * The dashboard is a React app, so its wording does not come from PHP's __() calls and * would otherwise stay English forever (docs/08 muc 3.46). Attached to the handle that * has just been enqueued, and only when a translation for the chosen language exists. */ MYBOT_I18n_Shim::inline_script_translations( 'mybot-admin-react' ); } } /** * Adds quick links below the plugin name. * * @param array $links The list of links. * @return array */ public function action_links( $links ) { array_unshift( $links, sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::REQUESTS_SLUG ) ), esc_html__( 'Requests', 'aihouse' ) ), sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ), esc_html__( 'Settings', 'aihouse' ) ) ); return $links; } /** * Adds a AI House shortcut to the WordPress admin toolbar. * * The node sits on the left of the toolbar, straight after the site name, so the * chatbot is one click away from any screen in the admin without opening the menu. * Priority 35 places it between the site name (30) and the updates node (60). * * @param WP_Admin_Bar $wp_admin_bar The admin bar instance. * @return void */ public function add_admin_bar_node( $wp_admin_bar ) { if ( ! current_user_can( 'manage_options' ) ) { return; } $dashboard = admin_url( 'admin.php?page=' . self::PAGE_SLUG ); $knowledge = admin_url( 'edit.php?post_type=' . MYBOT_Knowledge_CPT::POST_TYPE ); $wp_admin_bar->add_node( array( 'id' => 'mybot', 'title' => '' . '' . esc_html__( 'AI House', 'aihouse' ) . '', 'href' => $dashboard, 'meta' => array( 'title' => esc_attr__( 'Open AI House', 'aihouse' ), ), ) ); $children = array( 'mybot-overview' => array( __( 'Overview', 'aihouse' ), $dashboard ), 'mybot-requests' => array( sprintf( /* translators: %d: number of questions waiting for a human */ __( 'Requests (%d)', 'aihouse' ), MYBOT_Requests::counts()['new'] ), admin_url( 'admin.php?page=' . self::REQUESTS_SLUG ), ), 'mybot-knowledge' => array( __( 'Knowledge', 'aihouse' ), $knowledge ), 'mybot-settings' => array( __( 'Settings', 'aihouse' ), add_query_arg( 'view', 'settings', $dashboard ) ), ); foreach ( $children as $id => $child ) { $wp_admin_bar->add_node( array( 'id' => $id, 'parent' => 'mybot', 'title' => esc_html( $child[0] ), 'href' => esc_url( $child[1] ), ) ); } } /** * Renders the settings page. * * @return void */ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'aihouse' ) ); } $settings = $this->settings; $plugin = mybot(); require MYBOT_PLUGIN_DIR . 'admin/partials/mybot-admin-settings.php'; } }