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';
}
}