load_dependencies(); $this->boot_services(); $this->define_hooks(); $this->loader->run(); } /** * Prevent cloning of the instance. * * @return void */ private function __clone() { } /** * Load all dependency files. * * @return void */ protected function load_dependencies() { $dir = MYBOT_PLUGIN_DIR . 'includes/'; // Infrastructure. require_once $dir . 'class-mybot-http.php'; require_once $dir . 'class-mybot-languages.php'; require_once $dir . 'class-mybot-settings.php'; require_once $dir . 'class-mybot-security.php'; require_once $dir . 'class-mybot-rate-limiter.php'; require_once $dir . 'class-mybot-circuit-breaker.php'; require_once $dir . 'class-mybot-cache.php'; require_once $dir . 'class-mybot-queue.php'; require_once $dir . 'class-mybot-logger.php'; require_once $dir . 'class-mybot-attachments.php'; require_once $dir . 'class-mybot-contacts.php'; require_once $dir . 'class-mybot-contacts-privacy.php'; require_once $dir . 'class-mybot-supabase-log.php'; require_once $dir . 'class-mybot-supabase-requests.php'; require_once $dir . 'class-mybot-requests.php'; require_once $dir . 'class-mybot-support-notify.php'; require_once $dir . 'class-mybot-knowledge-cpt.php'; require_once $dir . 'class-mybot-citations.php'; require_once $dir . 'class-mybot-human-intent.php'; require_once $dir . 'class-mybot-citations-admin.php'; require_once $dir . 'class-mybot-knowledge-importer.php'; require_once $dir . 'class-mybot-knowledge-lock.php'; // Add-on registry — the API other plugins are written against (docs/13 muc 3). require_once $dir . 'class-mybot-addons.php'; // Branch 2a — direct LLM calls. require_once $dir . 'llm/interface-mybot-llm-provider.php'; require_once $dir . 'llm/class-mybot-llm-content.php'; require_once $dir . 'llm/class-mybot-llm-openai.php'; require_once $dir . 'llm/class-mybot-llm-openrouter.php'; require_once $dir . 'llm/class-mybot-llm-gemini.php'; require_once $dir . 'llm/class-mybot-llm-factory.php'; // Branch 2b — RAG through the vector DB. require_once $dir . 'vector/interface-mybot-vector-store.php'; require_once $dir . 'vector/class-mybot-vector-supabase.php'; require_once $dir . 'vector/class-mybot-vector-pinecone.php'; require_once $dir . 'vector/class-mybot-vector-qdrant.php'; require_once $dir . 'vector/class-mybot-vector-factory.php'; require_once $dir . 'rag/class-mybot-chunker.php'; require_once $dir . 'rag/class-mybot-retriever.php'; require_once $dir . 'rag/class-mybot-indexer.php'; // REST API. require_once $dir . 'rest/class-mybot-rest-controller.php'; require_once $dir . 'rest/class-mybot-rest-chat.php'; require_once $dir . 'rest/class-mybot-rest-config.php'; require_once $dir . 'rest/class-mybot-rest-search.php'; require_once $dir . 'rest/class-mybot-rest-index.php'; require_once $dir . 'rest/class-mybot-rest-knowledge.php'; require_once $dir . 'rest/class-mybot-rest-support.php'; require_once $dir . 'rest/class-mybot-rest-attachment.php'; require_once $dir . 'rest/class-mybot-rest-contact.php'; // Admin and public. require_once MYBOT_PLUGIN_DIR . 'admin/class-mybot-admin.php'; require_once MYBOT_PLUGIN_DIR . 'public/class-mybot-public.php'; $this->loader = new MYBOT_Loader(); } /** * Instantiate the shared services. * * @return void */ protected function boot_services() { $this->settings = new MYBOT_Settings(); $this->languages = new MYBOT_Languages( $this->settings ); $this->cache = new MYBOT_Cache(); $this->queue = new MYBOT_Queue(); $this->logger = new MYBOT_Logger(); $this->rate_limiter = new MYBOT_Rate_Limiter( $this->settings ); $this->circuit = new MYBOT_Circuit_Breaker(); $this->security = new MYBOT_Security( $this->settings, $this->rate_limiter ); $this->retriever = new MYBOT_Retriever( $this->settings ); $this->indexer = new MYBOT_Indexer( $this->settings ); $this->knowledge = new MYBOT_Knowledge_CPT(); $this->addons = new MYBOT_Addons(); } /** * Let add-ons declare themselves. * * The action fires on `plugins_loaded`, which is late enough that every plugin is loaded and early enough * that nothing of ours has hooked its own behaviour yet — so an add-on can add its filters before the * first REST request or the first widget is rendered, and none of its declarations arrive too late to * matter. * * It is a separate method rather than three lines inside `define_hooks()` because the ORDER is the * contract: `docs/13` muc 3.2 says the core must finish loading before this fires, and a reader looking * for that rule should find it in one place. * * @return void */ public function register_addons() { /** * Fires so that add-on plugins can register themselves. * * @param MYBOT_Addons $addons The registry. */ do_action( 'mybot_register_addons', $this->addons ); } /** * Wire up all hooks. * * @return void */ protected function define_hooks() { $i18n = new MYBOT_I18n_Shim(); $admin = new MYBOT_Admin( $this->settings ); $public = new MYBOT_Public( $this->settings ); $rest = new MYBOT_REST_Controller(); $this->loader->add_action( 'init', $i18n, 'load_textdomain' ); /* * Add-ons declare themselves once the core is fully loaded. * * `plugins_loaded` is the right moment and not `init`: every plugin is loaded by then, and nothing of * ours has hooked its behaviour yet, so a declaration cannot arrive too late to matter. Priority 20 so * that an add-on which itself hooks `plugins_loaded` can still set something up first. */ $this->loader->add_action( 'plugins_loaded', $this, 'register_addons', 20 ); // Knowledge repository imported from files. $this->loader->add_action( 'init', $this->knowledge, 'register', 5 ); // REST API. $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); /* * The WordPress privacy tools (1.17.0). * * A visitor card is personal data the site asked for, so the site has to be able to answer the two * questions Nghị định 13/2023 (and the GDPR) give every person: *what do you hold about me*, and * *delete it*. WordPress already has the screens for both — Tools → Export Personal Data and Tools → * Erase Personal Data — and they only work if a plugin registers itself. Until this release neither * this plugin nor any other knew that `mybot_contacts` existed, so the answer to both questions was * "open phpMyAdmin and look". */ $this->loader->add_filter( 'wp_privacy_personal_data_exporters', 'MYBOT_Contacts_Privacy', 'register_exporter' ); $this->loader->add_filter( 'wp_privacy_personal_data_erasers', 'MYBOT_Contacts_Privacy', 'register_eraser' ); // Admin. $this->loader->add_action( 'admin_menu', $admin, 'add_menu' ); $this->loader->add_action( 'admin_init', $admin, 'register_settings' ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_assets' ); $this->loader->add_filter( 'plugin_action_links_' . MYBOT_PLUGIN_BASENAME, $admin, 'action_links' ); /* * The two admin actions of the visitor cards (1.17.0). * * `admin_post_*` is the WordPress door for "a form or a link in wp-admin did something": it runs * before any theme output — which a CSV download needs, because the file is the whole response — and * it is the door a nonce belongs on. Both handlers check the permission AND the nonce themselves; * hooking them is not the guard. */ $this->loader->add_action( 'admin_post_mybot_contacts_export', $admin, 'handle_contacts_export' ); $this->loader->add_action( 'admin_post_mybot_contacts_delete', $admin, 'handle_contacts_delete' ); /* * The knowledge lock has NO admin action any more (1.10.0). It used to post through * admin-post.php, guarded by a code stored in the lock file; the buttons, the code, the * one-time notice and the fresh-install lock were removed at the owner's request, because * that code path was the part that kept failing (docs/08 muc 6.51). The lock file is now the * only switch, and nothing in the plugin writes it. */ // Shortcut on the WordPress admin toolbar, on the left after the site name. $this->loader->add_action( 'admin_bar_menu', $admin, 'add_admin_bar_node', 35 ); // Public. $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_assets' ); $this->loader->add_action( 'wp_footer', $public, 'render_launcher' ); $this->loader->add_action( 'init', $public, 'register_shortcode' ); // Background queue. $this->loader->add_action( MYBOT_Queue::CRON_HOOK, $this->queue, 'process_batch' ); $this->loader->add_action( 'mybot_daily_maintenance', $this, 'run_maintenance' ); $this->loader->add_filter( 'cron_schedules', 'MYBOT_Activator', 'add_cron_schedule' ); // Upgrade the schema when the database version changes (runs after the plugin is initialized). $this->loader->add_action( 'plugins_loaded', 'MYBOT_Activator', 'maybe_upgrade', 20 ); // Upgrade saved content defaults that still hold a legacy Vietnamese value. $this->loader->add_action( 'plugins_loaded', 'MYBOT_Activator', 'maybe_upgrade_content_defaults', 21 ); // Background job handlers. $this->loader->add_filter( 'mybot_queue_handlers', $this->logger, 'register_handlers' ); $this->loader->add_filter( 'mybot_queue_handlers', $this->indexer, 'register_handlers' ); // Track content changes so knowledge can be re-indexed. $this->indexer->register_hooks(); } /** * Run the daily maintenance job. * * @return void */ public function run_maintenance() { $this->logger->purge_old( (int) $this->settings->get( 'log_retention_days', 30 ) ); $this->queue->purge_finished(); $this->cache->purge_expired(); $this->rate_limiter->purge_expired(); } } /** * Translation loader class, kept separate for clarity. * * @package AI House */ class MYBOT_I18n_Shim { /** * Constructor: register the locale filter as early as the plugin can. * * Earlier than `load_textdomain()`, deliberately. WordPress 6.7+ loads a text domain JUST IN TIME * — on the first `__()` call that asks for it — and that call can happen before `init`. A filter * registered inside `load_textdomain()` would miss that path entirely, and the chosen language * would silently not apply on exactly the sites where something loaded early. * * @return void */ public function __construct() { add_filter( 'plugin_locale', array( $this, 'filter_locale' ), 10, 2 ); } /** * Load the .mo file. * * @return void */ public function load_textdomain() { load_plugin_textdomain( 'aihouse', false, dirname( MYBOT_PLUGIN_BASENAME ) . '/languages' ); } /** * Use the interface language chosen in Settings, for this text domain only. * * WHY: the admin screens of a WordPress plugin follow the language of the user's profile, so a * shop owner whose WordPress is in English gets an English AI House even when they would rather * read Vietnamese — and the plugin cannot know that. `plugin_locale` is the one documented hook * that changes the language of a single text domain without touching the rest of wp-admin, so * the setting stays a plugin choice rather than a site-wide surprise. * * The empty string means "follow WordPress": the default, and what every site had before 1.8.0. * * @param string $locale Locale WordPress would use. * @param string $domain Text domain being loaded. * @return string */ public function filter_locale( $locale, $domain ) { if ( 'aihouse' !== $domain ) { return $locale; } if ( ! $this->applies_here() ) { return $locale; } // The filter can run while the plugin object is still being put together; nothing to read yet. $plugin = function_exists( 'mybot' ) ? mybot() : null; if ( ! $plugin || ! isset( $plugin->settings ) ) { return $locale; } $chosen = (string) $plugin->settings->get( 'admin_language', '' ); if ( '' === $chosen ) { return $locale; } $available = MYBOT_Languages::admin_locale_options(); return isset( $available[ $chosen ] ) ? $chosen : $locale; } /** * Whether this request is one where the chosen interface language should apply. * * Deliberately narrow, and the reason is worth stating: the FRONT END is read by visitors, not * by the site owner. If the owner picks Vietnamese for their own screens, an English-speaking * visitor must still get "Send" and "Open chat" — so the front end keeps following the site * language. The plugin's own REST routes are included because the dashboard reads most of its * wording from REST responses: leaving them out would print two languages on one screen. * * @return bool */ protected function applies_here() { $applies = is_admin(); if ( ! $applies ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never printed. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; $applies = ( false !== strpos( $uri, '/' . MYBOT_REST_NAMESPACE . '/' ) ); } /** Filters whether the chosen interface language applies to this request. */ return (bool) apply_filters( 'mybot_admin_language_applies', $applies ); } /** * Hand the React dashboard its translations for the chosen language. * * `wp_set_script_translations()` is deliberately NOT used: it looks up the file for the locale of * the user profile, which is exactly the locale this setting overrides. On a site whose profile * language differs from the chosen one it would ask for a file that does not exist and leave the * dashboard in English — which is precisely what happened before 1.8.0, when there was no file at * all: every PHP screen was translated and the dashboard was not. * * The Jed data is the same shape WordPress itself inlines, read from * `languages/aihouse-.json` — generated by `tools/build-js-translations.mjs`, and kept * honest by that tool's `--check` mode. The prefix is the text domain, which is the WordPress.org slug. * * @param string $handle Script handle to attach the data to. * @return void */ public static function inline_script_translations( $handle ) { $settings = mybot()->settings; $chosen = (string) $settings->get( 'admin_language', '' ); $locale = ( '' !== $chosen ) ? $chosen : determine_locale(); $file = MYBOT_PLUGIN_DIR . 'languages/aihouse-' . $locale . '.json'; if ( ! file_exists( $file ) ) { return; } $data = json_decode( (string) file_get_contents( $file ), true ); if ( ! is_array( $data ) ) { return; } /* * The slice is accepted in either shape on purpose. WordPress inlines `locale_data[ domain ]`, * while the file on disk holds the whole Jed object — accepting both means this keeps working * whichever way the file is later regenerated, instead of failing silently into English. */ $script = '( function ( data ) {' . ' if ( ! window.wp || ! wp.i18n || ! wp.i18n.setLocaleData ) { return; }' . ' var slice = ( data.locale_data && ( data.locale_data.mybot || data.locale_data.messages ) ) || data;' . ' wp.i18n.setLocaleData( slice, "mybot" );' . ' } )( ' . wp_json_encode( $data ) . ' );'; wp_add_inline_script( $handle, $script, 'before' ); } }