" ); define( 'MWAI_IMG_WAND_HTML_XS', "AI Wand" ); class Meow_MWAI_Core { public $admin = null; public $is_rest = false; public $is_cli = false; public $site_url = null; public $files = null; public $tasks = null; public $magicWand = null; // When something was last pushed to the browser on a stream (see stream_keepalive in engines/core.php). public $lastStreamPush = 0; // Set when a stream was stopped because the visitor left, so the resulting curl abort is not logged as an error. public $streamVisitorLeft = false; private $options = null; private $option_name = 'mwai_options'; private $themes_option_name = 'mwai_themes'; private $chatbots_option_name = 'mwai_chatbots'; private $nonce = null; private $orphan_shortcodes = []; public $chatbot = null; public $discussions = null; public $search = null; public $advisor = null; // Service instances for improved architecture public $responseIdManager = null; public $messageBuilder = null; public $sessionService = null; public $imageService = null; public $usageStatsService = null; public $modelEnvironmentService = null; public $settingsBackups = null; public function __construct() { // Remembered once per site, before anything writes mwai_options: when AI Engine first ran // here, or 0 if the site already had settings (an update, not a fresh install). Onboarding // meant for new sites only (the MCP notice) checks it, so existing sites are never nagged. if ( get_option( 'mwai_first_activated' ) === false ) { add_option( 'mwai_first_activated', get_option( 'mwai_options' ) === false ? time() : 0 ); } Meow_MWAI_Logging::init( 'mwai_options', 'AI Engine' ); $this->site_url = get_site_url(); $this->is_rest = MeowKit_MWAI_Helpers::is_rest(); $this->is_cli = defined( 'WP_CLI' ); $this->files = new Meow_MWAI_Modules_Files( $this ); $this->tasks = new Meow_MWAI_Modules_Tasks( $this ); $this->advisor = new Meow_MWAI_Modules_Advisor( $this ); // Load task examples in Dev Mode if ( $this->get_option( 'dev_mode' ) ) { new Meow_MWAI_Modules_Tasks_Examples( $this ); } add_action( 'plugins_loaded', [ $this, 'init' ] ); add_action( 'wp_register_script', [ $this, 'register_scripts' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'register_scripts' ] ); } #region Init & Scripts public function init() { global $mwai; // Language load_plugin_textdomain( MWAI_DOMAIN, false, basename( MWAI_PATH ) . '/languages' ); $this->chatbot = null; $this->discussions = null; // Initialize services here after autoloader is ready $this->responseIdManager = new Meow_MWAI_Services_ResponseIdManager( $this ); $this->messageBuilder = new Meow_MWAI_Services_MessageBuilder( $this ); $this->sessionService = new Meow_MWAI_Services_Session( $this ); $this->imageService = new Meow_MWAI_Services_Image( $this ); $this->usageStatsService = new Meow_MWAI_Services_UsageStats( $this ); $this->modelEnvironmentService = new Meow_MWAI_Services_ModelEnvironment( $this ); $this->settingsBackups = new Meow_MWAI_Services_SettingsBackups(); // Start session early if needed for REST requests if ( $this->is_rest && $this->sessionService->can_start_session() ) { session_start(); } new Meow_MWAI_Modules_Security( $this ); // REST API if ( $this->is_rest ) { new Meow_MWAI_Rest( $this ); } // WP Admin if ( is_admin() ) { new Meow_MWAI_Admin( $this ); } // GDPR Module if ( $this->get_option( 'chatbot_gdpr_consent' ) ) { new Meow_MWAI_Modules_GDPR( $this ); } // Suggestions Module if ( $this->get_option( 'module_suggestions' ) && ( is_admin() || $this->is_rest ) ) { $this->magicWand = new Meow_MWAI_Modules_Wand( $this ); } // Chatbots & Discussions if ( $this->get_option( 'module_chatbots' ) ) { $this->chatbot = new Meow_MWAI_Modules_Chatbot(); // Only instantiate discussions if the feature is enabled if ( $this->get_option( 'chatbot_discussions' ) ) { $this->discussions = new Meow_MWAI_Modules_Discussions(); } } // Search if ( $this->get_option( 'module_search' ) ) { $this->search = new Meow_MWAI_Modules_Search( $this ); } // Workspace (full-screen chat surface in wp-admin; admins only for now). // Free shell; Knowledge, MCP Servers and Functions inside it are Pro. if ( $this->get_option( 'module_workspace' ) && ( is_admin() || $this->is_rest ) ) { new Meow_MWAI_Modules_Workspace( $this ); } // Forms Manager (standalone Forms UI + shortcode renderer) if ( $this->get_option( 'module_forms' ) ) { new Meow_MWAI_Modules_Forms_Manager( $this ); } // Advanced Core if ( class_exists( 'MeowPro_MWAI_Core' ) ) { new MeowPro_MWAI_Core( $this ); } // Editor Assistant: Pro extends with tools and feedback; free version only provides recommendations. // Must be after MeowPro_MWAI_Core so the Pro class is already instantiated. if ( $this->get_option( 'module_assistant', true ) && ( is_admin() || $this->is_rest ) ) { if ( !class_exists( 'MeowPro_MWAI_EditorAssistant', false ) ) { new Meow_MWAI_Modules_Editor_Assistant( $this ); } } // Simple API $mwai = new Meow_MWAI_API( $this->chatbot, $this->discussions ?? null ); // MCP if ( $this->get_option( 'module_mcp' ) ) { new Meow_MWAI_Labs_MCP( $this ); // Core - Core WordPress MCP tools if ( $this->get_option( 'mcp_core' ) ) { new Meow_MWAI_Labs_MCP_Core( $this ); new Meow_MWAI_Labs_MCP_Site(); new Meow_MWAI_Labs_MCP_Seo(); new Meow_MWAI_Labs_MCP_Acf(); new Meow_MWAI_Labs_MCP_Ops(); new Meow_MWAI_Labs_MCP_Extras(); new Meow_MWAI_Labs_MCP_Shortcodes(); } // Abilities - tools registered by plugins through the WordPress Abilities API if ( $this->get_option( 'mcp_abilities' ) ) { new Meow_MWAI_Labs_MCP_Abilities(); } // Dynamic REST - WordPress REST API MCP tools if ( $this->get_option( 'mcp_dynamic_rest' ) ) { require_once MWAI_PATH . '/labs/mcp-rest.php'; new Meow_MWAI_Labs_MCP_Rest(); } // Themes - Pro theme management MCP tools if ( $this->get_option( 'mcp_themes' ) && class_exists( 'MeowPro_MWAI_MCP_Theme' ) ) { new MeowPro_MWAI_MCP_Theme( $this ); } // Plugins - Pro plugin management MCP tools if ( $this->get_option( 'mcp_plugins' ) && class_exists( 'MeowPro_MWAI_MCP_Plugin' ) ) { new MeowPro_MWAI_MCP_Plugin( $this ); } // Database - Pro database query MCP tools if ( $this->get_option( 'mcp_database' ) && class_exists( 'MeowPro_MWAI_MCP_Database' ) ) { new MeowPro_MWAI_MCP_Database( $this ); } // Polylang - Pro multilingual MCP tools (only if Polylang is active) if ( $this->get_option( 'mcp_polylang' ) && class_exists( 'MeowPro_MWAI_MCP_Polylang' ) && function_exists( 'pll_get_post_language' ) ) { new MeowPro_MWAI_MCP_Polylang( $this ); } // WPML - Pro multilingual MCP tools (only if WPML is active) if ( $this->get_option( 'mcp_wpml' ) && class_exists( 'MeowPro_MWAI_MCP_Wpml' ) && defined( 'ICL_SITEPRESS_VERSION' ) ) { new MeowPro_MWAI_MCP_Wpml( $this ); } // Elementor - Pro page editing MCP tools (only if Elementor is active) if ( $this->get_option( 'mcp_elementor' ) && class_exists( 'MeowPro_MWAI_MCP_Elementor' ) && defined( 'ELEMENTOR_VERSION' ) ) { new MeowPro_MWAI_MCP_Elementor( $this ); } // Beaver Builder - Pro page editing MCP tools (only if Beaver Builder is active) if ( $this->get_option( 'mcp_beaver' ) && class_exists( 'MeowPro_MWAI_MCP_Beaver' ) && class_exists( 'FLBuilderModel' ) ) { new MeowPro_MWAI_MCP_Beaver( $this ); } // WooCommerce - Pro WooCommerce store management MCP tools (only if WooCommerce is active) if ( $this->get_option( 'mcp_woocommerce' ) && class_exists( 'MeowPro_MWAI_MCP_WooCommerce' ) && class_exists( 'WooCommerce' ) ) { new MeowPro_MWAI_MCP_WooCommerce( $this ); } } // WP 7 Connectors integration (self-gates on WP 7+). if ( class_exists( 'WP_Connector_Registry' ) ) { new Meow_MWAI_Labs_WPAI_Connectors( $this ); } // WP 7 AiClient gateway: register AI Engine as a provider in the // AiClient registry so the WP AI framework can dispatch through us. if ( class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { new Meow_MWAI_Labs_WPAI_Gateway( $this ); } // Runs after every module above has had its chance to register its own shortcodes. add_action( 'init', [ $this, 'neutralize_orphan_shortcodes' ], 99 ); add_filter( 'render_block_ai-engine/form-container', [ $this, 'hide_orphan_form_container' ] ); } /** * A module that is switched off never registers its shortcodes, and WordPress then prints * them to visitors as raw text, internal ids and url-encoded prompts included. A page built * with AI Forms showed its whole markup that way once the module was turned off. Anything * still unregistered by now renders nothing instead. */ public function neutralize_orphan_shortcodes() { $tags = [ 'mwai_chatbot', 'mwai_discussions', 'mwai_form', 'mwai_stats', 'mwai_stats_current', 'mwai-form-field', 'mwai-form-upload', 'mwai-form-submit', 'mwai-form-reset', 'mwai-form-output', 'mwai-form-conditional' ]; foreach ( $tags as $tag ) { if ( !shortcode_exists( $tag ) ) { add_shortcode( $tag, '__return_empty_string' ); $this->orphan_shortcodes[] = $tag; } } } /** * Emptying the shortcodes above still leaves the AI Form container block on the page, and the * chatbot theme gives it a padding and a solid background, so a visitor sees a coloured bar * with nothing in it. When every shortcode inside the container is one we neutralized, and * nothing else would show, drop the container too. * * This runs before do_shortcode (do_blocks is priority 9 on the_content, do_shortcode is 11), * so the inner shortcodes are still raw text here. That is exactly what makes the check work: * when AI Forms is on, its tags are not in $orphan_shortcodes, so their text survives the strip * and the container is kept. */ public function hide_orphan_form_container( $block_content ) { if ( empty( $this->orphan_shortcodes ) ) { return $block_content; } $remaining = $block_content; foreach ( $this->orphan_shortcodes as $tag ) { $remaining = preg_replace( '/\[' . preg_quote( $tag, '/' ) . '[^\]]*\]/', '', $remaining ); } // Anything that draws without text of its own keeps the container alive. if ( preg_match( '/<(img|iframe|input|button|svg|video|audio)\b/i', $remaining ) ) { return $block_content; } return trim( wp_strip_all_tags( $remaining ) ) === '' ? '' : $block_content; } public function register_scripts() { // Register Highlight.js wp_register_script( 'mwai_highlight', MWAI_URL . 'vendor/highlightjs/highlight.min.js', [], '11.7', false ); // Register CSS for the themes $themes = $this->get_themes(); foreach ( $themes as $theme ) { if ( $theme['type'] === 'internal' ) { $themeId = $theme['themeId']; $filename = $themeId . '.css'; $physical_file = trailingslashit( MWAI_PATH ) . 'themes/' . $filename; $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION; wp_register_style( 'mwai_chatbot_theme_' . $themeId, trailingslashit( MWAI_URL ) . 'themes/' . $filename, [], $cache_buster ); } } } public function enqueue_theme( $themeId ) { if ( empty( $themeId ) ) { return; } wp_enqueue_style( "mwai_chatbot_theme_$themeId" ); } public function enqueue_themes() { $themes = $this->get_themes(); foreach ( $themes as $theme ) { if ( $theme['type'] === 'internal' ) { $this->enqueue_theme( $theme['themeId'] ); } } } #endregion #region Roles & Capabilities public function can_start_session() { return $this->sessionService->can_start_session(); } public function can_access_settings() { return apply_filters( 'mwai_allow_setup', current_user_can( 'manage_options' ) ); } public function can_access_features() { $editor_or_admin = current_user_can( 'editor' ) || current_user_can( 'administrator' ); return apply_filters( 'mwai_allow_usage', $editor_or_admin ); } public function can_access_public_api( $feature, $extra ) { $logged_in = is_user_logged_in(); return apply_filters( 'mwai_allow_public_api', $logged_in, $feature, $extra ); } #endregion #region AI-Related Helpers public function run_query( $query, $streamCallback = null, $markdown = false ) { // Allow to modify the query before it is sent. // Different query types have specific filters for type-safe modifications. if ( $query instanceof Meow_MWAI_Query_Embed ) { $query = apply_filters( 'mwai_ai_embeddings_query', $query ); } else if ( $query instanceof Meow_MWAI_Query_Feedback ) { $query = apply_filters( 'mwai_ai_feedback_query', $query ); } else { $query = apply_filters( 'mwai_ai_query', $query ); } // Ensure the query is still valid after filtering if ( !$query || !is_object( $query ) ) { throw new Exception( __( 'Invalid query object after filtering. The mwai_ai_query filter must return a valid query object.', 'ai-engine' ) ); } // Validate that embeddings queries have a non-empty message. Not empty(): it treats "0" as empty. if ( $query instanceof Meow_MWAI_Query_Embed && trim( $query->get_message() ) === '' ) { throw new Exception( __( 'Embeddings query cannot have an empty message. Please check that the conversation context is properly extracted.', 'ai-engine' ) ); } // Let's check the default environment and model. $this->validate_env_model( $query ); // MCP servers only reach the model on the engines that implement them. Asking any // other engine used to look like it worked: the model got no tools at all and made // the results up, table included. if ( isset( $query->mcpServers ) && !empty( $query->mcpServers ) ) { $mcpEngine = Meow_MWAI_Engines_Factory::get( $this, $query->envId ); if ( $mcpEngine && !$mcpEngine->supports_mcp_servers() ) { throw new Exception( __( 'This model cannot use MCP servers. Only OpenAI and Anthropic models can, for now. Turn the MCP servers off for this chat, or switch to one of those models.', 'ai-engine' ) ); } } // Create the engine based on the query's environment $engine = Meow_MWAI_Engines_Factory::get( $this, $query->envId ); // Let's run the query. $reply = $engine->run( $query, $streamCallback ); // Let's allow to modify the reply before it is sent. if ( $markdown ) { if ( $query instanceof Meow_MWAI_Query_Image || $query instanceof Meow_MWAI_Query_EditImage ) { $reply->result = ''; foreach ( $reply->results as $result ) { $reply->result .= "![Image]($result)\n"; } } } return $reply; } public function validate_env_model( $query ) { return $this->modelEnvironmentService->validate_env_model( $query ); } #endregion #region Text-Related Helpers // Clean the text perfectly, resolve shortcodes, etc, etc. public function clean_text( $rawText = '' ) { // Tags first, entities second. Decoding first turned an escaped "<48 dB" into "<48 dB", // which the tag stripper took for a tag and deleted with everything after it, so a post // lost the rest of its content in Knowledge and Content-Aware (Nicolas Drouet, 2026-10). $text = wp_strip_all_tags( $rawText ); $text = html_entity_decode( $text ); $text = preg_replace( '/[\r\n]+/', "\n", $text ); $text = preg_replace( '/\n+/', "\n", $text ); $text = preg_replace( '/\t+/', "\t", $text ); return $text . ' '; } // Make sure there are no duplicate sentences, and keep the length under a maximum length. public function clean_sentences( $text, $maxLength = null ) { // Step 1: Identify URLs and replace them with a placeholder. $urlPattern = '/\bhttps?:\/\/[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/))/'; preg_match_all( $urlPattern, $text, $urls ); $urlPlaceholders = []; foreach ( $urls[0] as $index => $url ) { $placeholder = '{urlPlaceholder' . $index . '}'; $text = str_replace( $url, $placeholder, $text ); $urlPlaceholders[$placeholder] = $url; } $maxLength = (int) ( $maxLength ? $maxLength : $this->get_option( 'context_max_length', 4096 ) ); // A zero or negative limit is a misconfiguration, never a request for no content. It used // to return an empty string for any input, which silently emptied post content: embeddings // stayed forever PENDING with only a log line, and chatbot context came back blank. Treat // it as unset and use the default instead. if ( $maxLength <= 0 ) { $maxLength = 4096; } // Japanese, Chinese and friends do not put a space after 。 so requiring trailing // whitespace made a whole CJK article count as one single sentence, which the loop // below then skipped for being over maxLength: the content came back empty and the // post stayed forever stale. Split on CJK punctuation with no whitespace needed, and // keep requiring it after Latin punctuation so "3.14" and "e.g." stay in one piece. $sentences = preg_split( '/(?<=[。.!?])|(?<=[.?!])\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY ); $hashes = []; $uniqueSentences = []; $total = 0; foreach ( $sentences as $sentence ) { $sentence = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $sentence ); $hash = md5( $sentence ); if ( !in_array( $hash, $hashes ) ) { $length = mb_strlen( $sentence, 'UTF-8' ); if ( $total + $length > $maxLength ) { continue; } $hashes[] = $hash; $uniqueSentences[] = $sentence; $total += $length; } } // A single sentence longer than maxLength is skipped by the loop above, so text with // no sentence punctuation at all (Thai, a wall of text) would still come back empty. // Trim it instead of dropping it: an over-long input must never produce no input. if ( empty( $uniqueSentences ) ) { $trimmed = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $text ); if ( $trimmed !== '' ) { $uniqueSentences = [ mb_substr( $trimmed, 0, $maxLength, 'UTF-8' ) ]; } } $freshText = implode( ' ', $uniqueSentences ); // Step 3: Restore URLs in the final text. foreach ( $urlPlaceholders as $placeholder => $url ) { $freshText = str_replace( $placeholder, $url, $freshText ); } $freshText = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $freshText ); return $freshText; } public function get_post_content( $postId, $maxLength = null ) { $text = $this->get_post_text( $postId ); if ( $text === false ) { return false; } return $this->finish_post_content( $text, $postId, $maxLength ); } private function finish_post_content( $text, $postId, $maxLength = null ) { $text = $this->clean_sentences( $text, $maxLength ); return apply_filters( 'mwai_post_content', $text, $postId ); } // The post rendered as clean text, before it is cut to a maximum length. private function get_post_text( $postId ) { // Ensure we get fresh post data by clearing cache clean_post_cache( $postId ); $post = get_post( $postId ); if ( !$post ) { return false; } $text = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId ); $pattern = '/\[mwai_.*?\]/'; $text = preg_replace( $pattern, '', $text ); if ( $this->get_option( 'resolve_shortcodes' ) ) { // Page builders that keep their layout outside post_content (Breakdance, Oxygen, Bricks) // render "the current post" from inside the_content. A sync runs from REST or cron, where // there is no current post, so they had nothing to render and the page came back empty. The // post is made current for the time of the filter, then whatever was there is put back. $previousPost = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; setup_postdata( $post ); try { $text = apply_filters( 'the_content', $text ); } finally { $GLOBALS['post'] = $previousPost; if ( $previousPost ) { setup_postdata( $previousPost ); } } } else { $pattern = "/\[[^\]]+\]/"; $text = preg_replace( $pattern, '', $text ); $pattern = "//"; $text = preg_replace( $pattern, '', $text ); } return $this->clean_text( $text ); } public function markdown_to_html( $content ) { $Parsedown = new Parsedown(); $content = $Parsedown->text( $content ); return $content; } public function get_post_language( $postId ) { $locale = get_locale(); $code = strtolower( substr( $locale, 0, 2 ) ); $humanLanguage = strtr( $code, MWAI_ALL_LANGUAGES ); $lang = apply_filters( 'wpml_post_language_details', null, $postId ); if ( !empty( $lang ) ) { $locale = $lang['locale']; $humanLanguage = $lang['display_name']; } return strtolower( "$locale ($humanLanguage)" ); } public function do_placeholders( $text ) { $defaultPlaceholders = [ // Date and time in a clear, AI-friendly format (e.g., "December 11, 2025 at 3:45 PM") 'DATE_TIME' => date_i18n( 'F j, Y \a\t g:i A' ), ]; $dataPlaceholders = $this->get_user_data(); if ( !empty( $dataPlaceholders ) ) { $defaultPlaceholders = array_merge( $defaultPlaceholders, $dataPlaceholders ); } $placeholders = apply_filters( 'mwai_placeholders', $defaultPlaceholders ); foreach ( $placeholders as $key => $value ) { $text = str_replace( '{' . $key . '}', $value ?? '', $text ); } // Unmatched {ALL_CAPS} tokens are left as-is on purpose. This runs after // content-aware has already injected {CONTENT}/{TITLE} page text into the // instructions, so a blanket "{FOO} -> [N/A]" catch-all would corrupt the // page's own content (e.g. an API doc mentioning {API_KEY}) and any literal // template text the admin wants the model to see. Known placeholders are // resolved by the loop above; everything else is real text, so pass it through. return $text; } #endregion #region Security Helpers /** * Sanitize file path to prevent PHAR deserialization attacks. * Strips dangerous stream wrappers that could trigger object injection. * * @param string $path The file path to sanitize. * @return string The sanitized file path. * @throws Exception If a dangerous stream wrapper is detected. */ public static function sanitize_file_path( $path ) { if ( empty( $path ) || !is_string( $path ) ) { return $path; } // List of dangerous stream wrappers that could trigger deserialization $dangerous_wrappers = [ 'phar://', 'php://', 'zip://', 'zlib://', 'data://', 'glob://', 'rar://', 'ogg://', 'expect://' ]; // Check if the path contains any dangerous wrappers $lower_path = strtolower( $path ); foreach ( $dangerous_wrappers as $wrapper ) { if ( strpos( $lower_path, $wrapper ) === 0 ) { throw new Exception( 'Invalid file path: Stream wrappers are not allowed for security reasons.' ); } } return $path; } /** * Resolve "." and ".." segments in a path WITHOUT touching the filesystem. * * Deliberately lexical, not realpath(): realpath() also resolves symlinks, and plenty * of real installs symlink a folder inside uploads (shared media between sites, local * dev, NFS mounts). Comparing a symlink-resolved file against a non-resolved base would * reject those perfectly legitimate setups. An attacker can only inject "../" into a * string, never create a symlink under uploads, so resolving the segments is exactly * the check that is needed and nothing more. * * @param string $path * @return string */ public static function normalize_path_lexically( $path ) { $path = str_replace( '\\', '/', (string) $path ); $isAbsolute = strpos( $path, '/' ) === 0; $resolved = []; foreach ( explode( '/', $path ) as $segment ) { if ( $segment === '' || $segment === '.' ) { continue; } if ( $segment === '..' ) { array_pop( $resolved ); continue; } $resolved[] = $segment; } return ( $isAbsolute ? '/' : '' ) . implode( '/', $resolved ); } /** * Whether a path sits inside a base directory, once "../" segments are resolved. * * @param string $path * @param string $base * @return bool */ public static function is_path_within( $path, $base ) { $path = self::normalize_path_lexically( $path ); $base = rtrim( self::normalize_path_lexically( $base ), '/' ); if ( $base === '' || $path === '' ) { return false; } return $path === $base || strpos( $path, $base . '/' ) === 0; } /** * Strip the parameters a client must never control out of a REST payload. * * Nulling a local $path inside a route handler is not enough: the raw params array is * still forwarded to the query, inject_params() repopulates $query->path from it, and * the engine then hands that straight to file_get_contents(). That is how the 3.6.4 * transcription fix was bypassed. Sanitizing the array itself covers every caller that * forwards params, present and future. * * Keys are normalized before matching because Meow_MWAI_Query_Base::convert_keys() * camel-cases them later: "path_" becomes "path" and "api_key" becomes "apiKey", so an * exact-match blocklist would let both through. * * @param array $params The client-supplied parameters. * @return array The parameters without the blocked keys. */ /** * Resolves a client-supplied attachment id to its file path, but only when the current user * may read that attachment. Media ids are guessable, so without this check any logged-in user * could feed another user's private upload to the transcription or image edit routes. * * @throws Exception Code 403 when the attachment exists but the user may not read it. */ public static function get_readable_attachment_path( $mediaId ) { $mediaId = intval( $mediaId ); if ( $mediaId <= 0 || get_post_type( $mediaId ) !== 'attachment' ) { throw new Exception( 'The media file cannot be found.' ); } if ( !current_user_can( 'read_post', $mediaId ) ) { throw new Exception( 'You are not allowed to access this media file.', 403 ); } $path = get_attached_file( $mediaId ); if ( empty( $path ) || !file_exists( $path ) ) { throw new Exception( 'The media file cannot be found.' ); } return $path; } // One line, always: a CR or LF from a visitor would forge extra error log entries, and the // Advisor reads that log back into an AI prompt (CVE-2026-96561). public static function log_safe( $message ) { return str_replace( [ "\r", "\n" ], ' ', (string) $message ); } public static function sanitize_rest_params( $params ) { if ( !is_array( $params ) ) { return []; } $blocked = [ 'apiKey', // Overrides the environment's provider key. 'path', // Reaches file_get_contents() via inject_params(): arbitrary file read. ]; $blocked = apply_filters( 'mwai_blocked_rest_params', $blocked, $params ); $normalize = function ( $key ) { return strtolower( str_replace( '_', '', (string) $key ) ); }; $blocked = array_map( $normalize, (array) $blocked ); foreach ( array_keys( $params ) as $key ) { if ( in_array( $normalize( $key ), $blocked, true ) ) { unset( $params[$key] ); } } return $params; } #endregion #region Image-Related Helpers public static function is_image( $file ) { global $mwai_core; if ( $mwai_core && $mwai_core->imageService ) { return $mwai_core->imageService->is_image( $file ); } // Fallback to original implementation if service not available $mimeType = self::get_mime_type( $file ); if ( strpos( $mimeType, 'image' ) !== false ) { return true; } return false; } public static function get_image_resolution( $url ) { global $mwai_core; if ( $mwai_core && $mwai_core->imageService ) { return $mwai_core->imageService->get_image_resolution( $url ); } // Fallback to original implementation if service not available if ( empty( $url ) ) { return null; } $headers = get_headers( $url, 1 ); if ( strpos( $headers[0], '200' ) === false ) { return null; } $image_info = getimagesize( $url ); if ( $image_info === false ) { return null; } return [ 'width' => $image_info[0], 'height' => $image_info[1] ]; } public static function get_mime_type( $file ) { global $mwai_core; if ( $mwai_core && $mwai_core->imageService ) { return $mwai_core->imageService->get_mime_type( $file ); } // Fallback implementation - this should rarely be used as imageService is initialized early Meow_MWAI_Logging::warn( 'get_mime_type called before imageService is available' ); // Basic extension-based detection only $extension = pathinfo( $file, PATHINFO_EXTENSION ); $extension = strtolower( $extension ); $mimeTypes = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', 'svg' => 'image/svg+xml', 'ico' => 'image/x-icon', 'pdf' => 'application/pdf', ]; return isset( $mimeTypes[$extension] ) ? $mimeTypes[$extension] : null; } public function download_image( $url ) { return $this->imageService->download_image( $url ); } /** * Add an image from a URL to the Media Library. * @param string $url The URL of the image to be downloaded. * @param string $filename The filename of the image, if not set, it will be the basename of the URL. * @param string $title The title of the image. * @param string $description The description of the image. * @param string $caption The caption of the image. * @param string $alt The alt text of the image. * @return int The attachment ID of the image. */ public function add_image_from_url( $url, $filename = null, $title = null, $description = null, $caption = null, $alt = null, $attachedPost = null, $post_status = 'inherit', $post_type = 'attachment', $ai_metadata = [] ) { return $this->imageService->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, $attachedPost, $post_status, $post_type, $ai_metadata ); } #endregion #region Context-Related Helpers public function retrieve_context( $params, $query, $streamCallback = null ) { $contextMaxLength = $params['contextMaxLength'] ?? $this->get_option( 'context_max_length', 4096 ); $embeddingsEnvId = $params['embeddingsEnvId'] ?? null; $context = apply_filters( 'mwai_context_search', [], $query, [ 'embeddingsEnvId' => $embeddingsEnvId, 'streamCallback' => $streamCallback ] ); // Emit embeddings event if streaming and context was found if ( $streamCallback && !empty( $context ) ) { $count = 0; if ( isset( $context['embeddings'] ) && is_array( $context['embeddings'] ) ) { $count = count( $context['embeddings'] ); } else if ( isset( $context['content'] ) ) { $count = 1; } if ( $count > 0 ) { $event = Meow_MWAI_Event::embeddings( $count ); $streamCallback( $event ); } } if ( empty( $context ) ) { return null; } else if ( !isset( $context['content'] ) ) { Meow_MWAI_Logging::warn( 'A context without content was returned.' ); return null; } $context['content'] = $this->clean_sentences( $context['content'], $contextMaxLength ); $context['length'] = strlen( $context['content'] ); return $context; } /** * Wrap context content with framing instructions for AI. * This helps the AI understand that the context is background knowledge. * * @param string $context The raw context content. * @return string The framed context with instructions. */ public function frame_context( $context ) { if ( empty( $context ) ) { return $context; } $framing = 'The following is your knowledge about this topic. ' . 'Use it naturally when relevant - never mention or acknowledge that this information was provided to you. ' . "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it."; $framing = apply_filters( 'mwai_context_framing', $framing, $context ); return $framing . "\n\n---\n" . $context . "\n---"; } #endregion #region Users/Sessions Helpers public function get_nonce( $force = false ) { return $this->sessionService->get_nonce( $force ); } // This is a bit hacky, but chatId needs to be retrieved or generated. // Maybe we can clean this up later. public function fix_chat_id( $query, $params ) { return $this->sessionService->fix_chat_id( $query, $params ); } public function get_session_id() { return $this->sessionService->get_session_id(); } /** * Get the Response ID Manager service */ public function get_response_id_manager() { return $this->responseIdManager; } /** * Get the Message Builder service */ public function get_message_builder() { return $this->messageBuilder; } // Get the UserID from the data, or from the current user public function get_user_id( $data = null ) { return $this->sessionService->get_user_id( $data ); } public function get_session_user_id() { return $this->sessionService->get_session_user_id(); } public function get_admin_user() { return $this->sessionService->get_admin_user(); } public function get_user_data() { return $this->sessionService->get_user_data(); } public function get_ip_address( $force = false ) { return $this->sessionService->get_ip_address( $force ); } #endregion #region Sanitization public function sanitize_sort( &$sort, $default_accessor = 'created', $default_order = 'DESC', $allowed_columns = [ 'created', 'updated', 'name', 'id', 'time', 'units', 'price' ] ) { // Ensure $sort is an array if ( !is_array( $sort ) ) { $sort = [ 'accessor' => $default_accessor, 'by' => $default_order ]; } // Extract and sanitize the accessor $sort_accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : $default_accessor; if ( !in_array( $sort_accessor, $allowed_columns ) ) { Meow_MWAI_Logging::error( "This sort accessor is not allowed ($sort_accessor)." ); $sort_accessor = $default_accessor; } // Extract and sanitize the sort order $sort_by = isset( $sort['by'] ) ? strtoupper( $sort['by'] ) : $default_order; if ( $sort_by !== 'ASC' && $sort_by !== 'DESC' ) { Meow_MWAI_Logging::error( "This sort order is not allowed ($sort_by)." ); $sort_by = $default_order; } // Update the sort array with sanitized values $sort['accessor'] = $sort_accessor; $sort['by'] = $sort_by; } #endregion #region Other Helpers public function safe_strlen( $string, $encoding = 'UTF-8' ) { if ( function_exists( 'mb_strlen' ) ) { return mb_strlen( $string, $encoding ); } else { // Fallback implementation for environments without mbstring extension return preg_match_all( '/./u', $string, $matches ); } } public function check_rest_nonce( $request ) { // REST NONCE VERIFICATION: // Validates nonce from X-WP-Nonce header using WordPress nonce system. // Returns: false (invalid), 1 (0-12 hours old), or 2 (12-24 hours old) // WordPress REST permission callbacks accept any truthy value as success. // The filter allows custom authorization logic if needed. $nonce = $request->get_header( 'X-WP-Nonce' ); $rest_nonce = wp_verify_nonce( $nonce, 'wp_rest' ); return apply_filters( 'mwai_rest_authorized', $rest_nonce, $request ); } public function get_random_id( $length = 8, $excludeIds = [] ) { $characters = '0123456789abcdefghijklmnopqrstuvwxyz'; $charactersLength = strlen( $characters ); $randomId = ''; for ( $i = 0; $i < $length; $i++ ) { $randomId .= $characters[ mt_rand( 0, $charactersLength - 1 ) ]; } if ( in_array( $randomId, $excludeIds ) ) { return $this->get_random_id( $length, $excludeIds ); } return $randomId; } public function is_url( $url ) { return strpos( $url, 'http' ) === 0 ? true : false; } public function get_post_types() { $excluded = [ 'attachment', 'revision', 'nav_menu_item' ]; $post_types = []; $types = get_post_types( [], 'objects' ); // Let's get the Post Types that are enabled for Embeddings Sync $embeddingsSettings = $this->get_option( 'embeddings' ); $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : []; foreach ( $types as $type ) { $forced = in_array( $type->name, $syncPostTypes ); // Should not be excluded. if ( !$forced && in_array( $type->name, $excluded ) ) { continue; } // Should be public. if ( !$forced && !$type->public ) { continue; } $post_types[] = [ 'name' => $type->labels->name, 'type' => $type->name, ]; } // Let's get the Post Types that are enabled for Embeddings Sync $embeddingsSettings = $this->get_option( 'embeddings' ); $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : []; return $post_types; } public function get_post( $post ) { if ( is_numeric( $post ) ) { // Force fresh retrieval to avoid cache issues clean_post_cache( $post ); $post = get_post( $post ); } if ( is_object( $post ) ) { $post = (array) $post; } if ( !is_array( $post ) ) { return null; } $language = $this->get_post_language( $post['ID'] ); $title = $post['post_title']; $excerpt = $post['post_excerpt']; $url = get_permalink( $post['ID'] ); // Knowledge keeps at least MWAI_KNOWLEDGE_MIN_LENGTH characters per post. Sites installed // before 2025-08 still have the old Context Max Length of 4,096 saved, so only the first // ~600 words of every post went into Knowledge, silently. $text = $this->get_post_text( $post['ID'] ); $contextMax = (int) $this->get_option( 'context_max_length', 4096 ); $maxLength = max( $contextMax, MWAI_KNOWLEDGE_MIN_LENGTH ); $content = $text === false ? false : $this->finish_post_content( $text, $post['ID'], $maxLength ); $checksum = wp_hash( $content . $title . $url ); // The checksum of the shorter content those sites embedded stays valid, so the longer // limit only applies when a post is edited or rebuilt (Force Recreate), never as a re-sync (and a // Rewrite Content bill) of every long post right after the update. $legacyChecksum = null; if ( $text !== false && $contextMax > 0 && $contextMax < $maxLength && mb_strlen( $text ) > $contextMax ) { $legacyContent = $this->finish_post_content( $text, $post['ID'], $contextMax ); $legacyChecksum = wp_hash( $legacyContent . $title . $url ); } return [ 'postId' => (int) $post['ID'], 'title' => $title, 'content' => $content, 'excerpt' => $excerpt, 'url' => $url, 'language' => $language ?? 'english', 'checksum' => $checksum, 'legacyChecksum' => $legacyChecksum, // Set when the post is longer than what Knowledge keeps, so the sync can say so. 'truncated' => $text !== false && mb_strlen( $text ) > $maxLength ? [ mb_strlen( $text ), $maxLength ] : null, ]; } /** * Format a date/time string into a human-readable format * @param string $date_string The date string to format * @return string Formatted date (e.g., "Just now", "5m ago", "2h ago", "3d ago", "Jan 20th") */ public function format_discussion_date( $date_string ) { $date = strtotime( $date_string ); $now = time(); $diff = $now - $date; // Less than a minute if ( $diff < 60 ) { return 'Just now'; } // Less than an hour if ( $diff < 3600 ) { $minutes = floor( $diff / 60 ); return $minutes . 'm ago'; } // Less than a day if ( $diff < 86400 ) { $hours = floor( $diff / 3600 ); return $hours . 'h ago'; } // Less than a week if ( $diff < 604800 ) { $days = floor( $diff / 86400 ); return $days . 'd ago'; } // Format as date, in the site's configured timezone (wp_date), not UTC. $is_current_year = wp_date( 'Y', $date ) === wp_date( 'Y', $now ); if ( $is_current_year ) { return wp_date( 'M jS', $date ); } else { return wp_date( 'M jS, Y', $date ); } } #endregion #region Usage & Costs // Quick and dirty token estimation // Let's keep this synchronized with Helpers in JS public static function estimate_tokens( $text = '', $model = null ): int { global $mwai_core; if ( $mwai_core && $mwai_core->usageStatsService ) { return $mwai_core->usageStatsService->estimate_tokens( $text, $model ); } // Fallback to original implementation if service not available if ( !is_string( $text ) ) { $text = is_array( $text ) || is_object( $text ) ? json_encode( $text ) : (string) $text; } $averageTokenLength = 4; $words = preg_split( '/\s+/', trim( $text ) ); $tokenCount = 0; foreach ( $words as $word ) { $tokenCount += ceil( strlen( $word ) / $averageTokenLength ); } return apply_filters( 'mwai_estimate_tokens', $tokenCount, $text ); } public function record_tokens_usage( $model, $in_tokens, $out_tokens = 0, $returned_price = null ) { return $this->usageStatsService->record_tokens_usage( $model, $in_tokens, $out_tokens, $returned_price ); } public function record_audio_usage( $model, $seconds ) { return $this->usageStatsService->record_audio_usage( $model, $seconds ); } public function record_images_usage( $model, $resolution, $images ) { return $this->usageStatsService->record_images_usage( $model, $resolution, $images ); } public function record_videos_usage( $model, $resolution, $seconds ) { return $this->usageStatsService->record_videos_usage( $model, $resolution, $seconds ); } #endregion #region Errors // The message visitors get when something breaks internally (a provider failure, a // function-call issue, a stream error). The real error is always logged, and admins // still see it; visitors only ever get this. Filterable so a site can match its own // tone and so a raw provider message (billing, quota, model errors) never leaks. // A reply cut by Max Tokens used to stop mid-sentence with no explanation, which looks // like a broken chatbot. Returns the line to append below it, or '' when it is complete. public static function get_truncated_notice( $reply, $context = 'chatbot' ) { if ( empty( $reply->truncated ) ) { return ''; } $maxTokens = $reply->query->maxTokens ?? null; Meow_MWAI_Logging::warn( "The reply was cut: it reached Max Tokens ({$maxTokens})." ); // A thinking model can spend the whole limit reasoning and never start the answer. $noAnswer = trim( (string) ( $reply->result ?? '' ) ) === ''; if ( $noAnswer ) { $notice = __( 'The AI used its whole length limit thinking and could not answer yet. Please try again.', 'ai-engine' ); } else { $notice = $context === 'chatbot' ? __( 'This answer was cut short because it reached its length limit. Just ask me to continue.', 'ai-engine' ) : __( 'This answer was cut short because it reached its length limit.', 'ai-engine' ); } if ( current_user_can( 'manage_options' ) && $maxTokens ) { // translators: %d is the Max Tokens value of the chatbot or form. $notice .= ' ' . sprintf( __( 'Admins: raise Max Tokens (currently %d) in its settings.', 'ai-engine' ), $maxTokens ); } $notice = apply_filters( 'mwai_reply_truncated_notice', $notice, $reply, $context ); return empty( $notice ) ? '' : "\n\n*" . $notice . '*'; } public static function get_public_error_message( $exception = null ) { $message = 'Oops! Something went wrong on the server. Please try again, and if you are the site developer, check the PHP Error Logs for details.'; return apply_filters( 'mwai_public_error_message', $message, $exception ); } #endregion #region Streaming public function stream_push( $data, $query = null ) { $this->lastStreamPush = time(); try { // Handle new Event objects if ( is_object( $data ) && method_exists( $data, 'to_array' ) ) { $data = $data->to_array(); } $data = apply_filters( 'mwai_stream_push', $data, $query ); $out = 'data: ' . json_encode( $data ); echo $out; echo "\n\n"; if ( ob_get_level() > 0 ) { ob_end_flush(); } flush(); } catch ( Exception $e ) { // Send error as proper SSE error event $errorMessage = self::get_public_error_message( $e ); error_log( '[AI Engine Stream Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() ); $errorData = [ 'type' => 'error', 'data' => $errorMessage ]; $out = 'data: ' . json_encode( $errorData ); echo $out; echo "\n\n"; if ( ob_get_level() > 0 ) { ob_end_flush(); } flush(); // Stop execution after sending error die(); } } #endregion #region Options public function get_themes() { $themes = get_option( $this->themes_option_name, [] ); $themes = empty( $themes ) ? [] : $themes; $internalThemes = [ 'chatgpt' => [ 'type' => 'internal', 'name' => 'ChatGPT', 'themeId' => 'chatgpt', 'settings' => [], 'style' => '' ], 'messages' => [ 'type' => 'internal', 'name' => 'Messages', 'themeId' => 'messages', 'settings' => [], 'style' => '' ], 'timeless' => [ 'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless', 'settings' => [], 'style' => '' ], 'foundation' => [ 'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation', 'settings' => [], 'style' => '' ], 'glass' => [ 'type' => 'internal', 'name' => 'Glass', 'themeId' => 'glass', 'settings' => [], 'style' => '' ], ]; $customThemes = []; foreach ( $themes as $theme ) { if ( isset( $internalThemes[$theme['themeId']] ) ) { $internalThemes[$theme['themeId']] = $theme; continue; } $customThemes[] = $theme; } return array_merge( array_values( $internalThemes ), $customThemes ); } public function update_themes( $themes ) { update_option( $this->themes_option_name, $themes ); return $themes; } /** * Returns the registered chatbots. Pass a $filters array to narrow the list, * e.g. get_chatbots( [ 'functions' => true ] ) to only keep chatbots whose * model supports function calling. No filters returns every chatbot, as before. */ // The chatbot defaults, with the texts visitors read translated into the site's language. // MWAI_CHATBOT_DEFAULT_PARAMS can't do it: constants are defined before translations load, // so a new site in French used to start with "Send" and "Hi! How can I help you?". // Only used for new chatbots and missing keys; texts already saved are never touched. private $chatbotDefaultParams = null; public function get_chatbot_default_params() { if ( !did_action( 'init' ) ) { return MWAI_CHATBOT_DEFAULT_PARAMS; } if ( is_null( $this->chatbotDefaultParams ) ) { // Visitors see these texts, so the site's language, not the admin's own. $switched = switch_to_locale( get_locale() ); $this->chatbotDefaultParams = array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, [ 'aiName' => __( 'AI: ', 'ai-engine' ), 'userName' => __( 'User: ', 'ai-engine' ), 'guestName' => __( 'Guest: ', 'ai-engine' ), 'textSend' => __( 'Send', 'ai-engine' ), 'textClear' => __( 'Clear', 'ai-engine' ), 'textInputPlaceholder' => __( 'Type your message...', 'ai-engine' ), 'startSentence' => __( 'Hi! How can I help you?', 'ai-engine' ), 'headerSubtitle' => __( 'Discuss with', 'ai-engine' ), ] ); if ( $switched ) { restore_previous_locale(); } } return $this->chatbotDefaultParams; } public function get_chatbots( $filters = [] ) { $chatbots = get_option( $this->chatbots_option_name, [] ); $hasChanges = false; $defaults = $this->get_chatbot_default_params(); if ( empty( $chatbots ) ) { $chatbots = [ array_merge( $defaults, ['name' => 'Default', 'botId' => 'default' ] ) ]; } $hasDefault = false; foreach ( $chatbots as &$chatbot ) { if ( $chatbot['botId'] === 'default' ) { $hasDefault = true; } // New chatbots open in a macOS frame (3.8.7). Chatbots saved before these keys existed // got the standard frame from the defaults below, so pin it first: an update must never // restyle a chatbot that is already live. Do the same for themeId if its default changes. foreach ( [ 'containerType' => 'standard', 'headerType' => 'standard' ] as $key => $legacy ) { if ( !isset( $chatbot[$key] ) ) { $chatbot[$key] = $legacy; $hasChanges = true; } } foreach ( $defaults as $key => $value ) { // Use default value if not set. if ( !isset( $chatbot[$key] ) ) { $chatbot[$key] = $value; } } /* This is the best section to rename fields. We did this in 2024 for context to instructions, and fileUpload to fileSearch. fileSearch is for assistant file search, and fileUpload is now for chatbot file upload (similar to vision, but for files instead of images). */ // Migrate old file upload params to new unified system if ( !isset( $chatbot['fileUpload'] ) ) { // Set fileUpload based on old params (imageUpload, fileUploads, or vision checkbox) $chatbot['fileUpload'] = !empty( $chatbot['imageUpload'] ) || ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ); $hasChanges = true; } // Ensure maxUploads is set if ( !isset( $chatbot['maxUploads'] ) ) { if ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ) { $chatbot['maxUploads'] = $chatbot['fileUploads']; } else { $chatbot['maxUploads'] = 1; // Default to 1 file } $hasChanges = true; } // Sync fileUploads with fileUpload and maxUploads for consistency if ( isset( $chatbot['fileUpload'] ) ) { $chatbot['fileUploads'] = $chatbot['fileUpload'] ? $chatbot['maxUploads'] : 0; $chatbot['multiUpload'] = $chatbot['fileUpload'] && $chatbot['maxUploads'] > 1; $chatbot['imageUpload'] = $chatbot['fileUpload']; // Keep imageUpload in sync } // Migration: DALL-E was removed (deprecated by OpenAI). Move chatbots to the image fallback. // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12). if ( isset( $chatbot['model'] ) && in_array( $chatbot['model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) { $chatbot['model'] = MWAI_FALLBACK_MODEL_IMAGES; $hasChanges = true; } // if ( isset( $chatbot['context'] ) ) { // $chatbot['instructions'] = $chatbot['context']; // unset( $chatbot['context'] ); // $hasChanges = true; // } } if ( !$hasDefault ) { $defaultBot = array_merge( $defaults, ['name' => 'Default', 'botId' => 'default' ] ); array_unshift( $chatbots, $defaultBot ); $hasChanges = true; } if ( $hasChanges ) { update_option( $this->chatbots_option_name, $chatbots ); } // Optional filtering by capability (e.g. only function-calling chatbots). if ( !empty( $filters['functions'] ) ) { $chatbots = array_values( array_filter( $chatbots, function ( $chatbot ) { return $this->chatbot_supports_functions( $chatbot ); } ) ); } return $chatbots; } /** * Whether a chatbot's model supports function calling. The 'functions' tag is * the canonical signal across all engines; dynamic providers (OpenRouter, etc.) * also expose it as a feature, so we accept both. */ public function chatbot_supports_functions( $chatbot ) { list( $envId, $modelId ) = $this->resolve_chatbot_env_model( $chatbot ); if ( empty( $modelId ) ) { return false; } $model = $this->find_model_data( $modelId, $envId ); if ( empty( $model ) ) { return false; } $tags = $model['tags'] ?? []; $features = $model['features'] ?? []; return in_array( 'functions', $tags, true ) || in_array( 'functions', $features, true ); } /** * Resolves the environment and model a chatbot will actually run with. A chatbot * left on "Default" stores no envId/model, so we have to apply the same fallbacks * as Meow_MWAI_Services_ModelEnvironment::validate_env_model(), otherwise such a * chatbot looks like it has no model at all (it was dropped by the 'functions' * filter of get_chatbots(), for instance). * Returns [ $envId, $modelId ], either of which can be null. */ public function resolve_chatbot_env_model( $chatbot ) { $envId = $chatbot['envId'] ?? ( $chatbot['environment'] ?? null ); $modelId = $chatbot['model'] ?? null; if ( empty( $envId ) && empty( $modelId ) ) { return [ $this->get_option( 'ai_default_env' ), $this->get_option( 'ai_default_model' ) ]; } // Model without an environment: find_model_data() already searches the engine // defaults and the custom models, so there is nothing else to resolve. if ( empty( $envId ) ) { return [ null, $modelId ]; } // Environment without a model: the first model of that environment is used. if ( empty( $modelId ) ) { $env = $this->get_ai_env( $envId ); if ( !empty( $env['models'] ) && is_array( $env['models'] ) ) { $firstModel = reset( $env['models'] ); if ( !empty( $firstModel['model'] ) ) { return [ $envId, $firstModel['model'] ]; } } return [ $envId, $this->get_option( 'ai_default_model' ) ]; } return [ $envId, $modelId ]; } /** * Resolve a model id to its metadata (tags, features, etc.). Static providers * (OpenAI, Anthropic) keep their models in 'ai_engines' by type; dynamic ones * (OpenRouter, Google) store them per environment in 'ai_envs'. We check the * environment first, then fall back to the engine defaults and custom models. */ public function find_model_data( $modelId, $envId = null ) { if ( empty( $modelId ) ) { return null; } $options = $this->get_all_options(); // 1. The chatbot's own environment (covers dynamically-fetched models). $envType = null; if ( !empty( $envId ) && !empty( $options['ai_envs'] ) ) { foreach ( $options['ai_envs'] as $env ) { if ( ( $env['id'] ?? null ) !== $envId ) { continue; } $envType = $env['type'] ?? null; foreach ( $env['models'] ?? [] as $model ) { if ( ( $model['model'] ?? null ) === $modelId ) { return $model; } } } } // 2. The engine defaults (constants), scoped to the env type when known. foreach ( $options['ai_engines'] ?? [] as $engine ) { if ( $envType !== null && ( $engine['type'] ?? null ) !== $envType ) { continue; } foreach ( $engine['models'] ?? [] as $model ) { if ( ( $model['model'] ?? null ) === $modelId ) { return $model; } } } // 3. Custom models. foreach ( $options['ai_models'] ?? [] as $model ) { if ( ( $model['model'] ?? null ) === $modelId ) { return $model; } } return null; } public function get_chatbot( $botId ) { $chatbots = $this->get_chatbots(); foreach ( $chatbots as $chatbot ) { if ( $chatbot['botId'] === (string) $botId ) { return $chatbot; } } return null; } public function get_embeddings_env( $envId ) { return $this->modelEnvironmentService->get_embeddings_env( $envId ); } public function get_ai_env( $envId ) { return $this->modelEnvironmentService->get_ai_env( $envId ); } public function get_assistant( $envId, $assistantId ) { return $this->modelEnvironmentService->get_assistant( $envId, $assistantId ); } public function get_theme( $themeId ) { $themes = $this->get_themes(); foreach ( $themes as $theme ) { if ( $theme['themeId'] === $themeId ) { // Append custom CSS to theme data for frontend rendering (check for non-empty trimmed string) if ( isset( $theme['settings']['customCSS'] ) && trim( $theme['settings']['customCSS'] ) !== '' ) { $customCSS = $theme['settings']['customCSS']; // Add theme class prefix to all CSS rules for proper scoping $themeClass = '.mwai-' . $themeId . '-theme'; $lines = explode( "\n", $customCSS ); $processedCSS = ''; $inRule = false; foreach ( $lines as $line ) { $trimmedLine = trim( $line ); // Skip empty lines and comments if ( empty( $trimmedLine ) || strpos( $trimmedLine, '/*' ) === 0 ) { $processedCSS .= $line . "\n"; continue; } // If line contains a selector (has { but not }) if ( strpos( $line, '{' ) !== false && strpos( $line, '}' ) === false ) { // Extract selector and the rest $parts = explode( '{', $line, 2 ); $selector = trim( $parts[0] ); // Add theme class prefix if not already present if ( strpos( $selector, $themeClass ) !== 0 ) { // Handle multiple selectors separated by comma $selectors = explode( ',', $selector ); $prefixedSelectors = array_map( function ( $sel ) use ( $themeClass ) { $sel = trim( $sel ); // Don't prefix if it's a keyframe or similar if ( strpos( $sel, '@' ) === 0 || strpos( $sel, 'from' ) === 0 || strpos( $sel, 'to' ) === 0 || preg_match( '/^\d+%/', $sel ) ) { return $sel; } return $themeClass . ' ' . $sel; }, $selectors ); $selector = implode( ', ', $prefixedSelectors ); } $processedCSS .= $selector . ' {' . ( isset( $parts[1] ) ? $parts[1] : '' ) . "\n"; $inRule = true; } else { $processedCSS .= $line . "\n"; } } $customCSS = $processedCSS; // For custom themes (type: 'css'), append to style property if ( $theme['type'] === 'css' ) { $theme['style'] = ( $theme['style'] ?? '' ) . "\n\n/* Custom CSS */\n" . $customCSS; } // For internal themes, add customCSS as a separate property else { $theme['customCSS'] = $customCSS; } } // Add CSS URL for cross-site and dynamic loading support // Internal themes can use physical file, custom themes use REST endpoint $theme_type = $theme['type'] ?? 'internal'; if ( $theme_type === 'internal' ) { $theme['cssUrl'] = MWAI_URL . 'themes/' . $themeId . '.css'; } else { // Custom themes use REST endpoint (requires Cross-Site module to be enabled) $theme['cssUrl'] = get_rest_url( null, 'mwai-ui/v1/cross-site/theme-css' ) . '?themeId=' . $themeId; } return $theme; } } return null; } public function update_chatbots( $chatbots ) { $htmlFields = [ 'instructions', 'textCompliance', 'aiName', 'userName', 'startSentence' ]; $keepLineReturnsFields = [ 'instructions' ]; $whiteSpacedFields = [ 'context' ]; // Boolean fields that need proper conversion $booleanFields = [ 'window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen', 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch', 'contentAware', 'aiAvatar', 'userAvatar', 'guestAvatar' ]; foreach ( $chatbots as &$chatbot ) { foreach ( $chatbot as $key => &$value ) { if ( in_array( $key, $htmlFields ) ) { $value = wp_kses_post( $value ); } else if ( in_array( $key, $whiteSpacedFields ) ) { $value = sanitize_textarea_field( $value ); } else if ( in_array( $key, $booleanFields ) ) { // Convert various representations to boolean if ( is_bool( $value ) ) { // Already boolean, keep as is } else if ( $value === 1 || $value === '1' || $value === true || $value === 'true' || $value === 'yes' ) { // These are true values $value = true; } else if ( $value === 0 || $value === '0' || $value === false || $value === 'false' || $value === 'no' || $value === '' || $value === null ) { // These are false values $value = false; } else { // Default to checking if not empty $value = !empty( $value ); } } else if ( $key === 'functions' ) { $functions = []; foreach ( $value as $function ) { if ( isset( $function['id'] ) && isset( $function['type'] ) ) { $functions[] = [ 'id' => sanitize_text_field( $function['id'] ), 'type' => sanitize_text_field( $function['type'] ), ]; } } $value = $functions; } else if ( $key === 'mcpServers' ) { $mcpServers = []; foreach ( $value as $server ) { if ( isset( $server['id'] ) ) { $mcpServers[] = [ 'id' => sanitize_text_field( $server['id'] ), ]; } } $value = $mcpServers; } else if ( $key === 'tools' ) { // Sanitize tools array (web_search, image_generation, thinking, etc) $tools = []; if ( is_array( $value ) ) { foreach ( $value as $tool ) { $sanitized_tool = sanitize_text_field( $tool ); if ( in_array( $sanitized_tool, ['web_search', 'image_generation', 'thinking', 'code_interpreter', 'google_maps'] ) ) { $tools[] = $sanitized_tool; } } } $value = $tools; } else if ( $key === 'crossSite' ) { // Handle crossSite object $crossSite = [ 'enabled' => isset( $value['enabled'] ) ? (bool) $value['enabled'] : false, 'allowedDomains' => [] ]; if ( isset( $value['allowedDomains'] ) && is_array( $value['allowedDomains'] ) ) { foreach ( $value['allowedDomains'] as $domain ) { $sanitized_domain = sanitize_text_field( $domain ); if ( !empty( $sanitized_domain ) ) { $crossSite['allowedDomains'][] = $sanitized_domain; } } } $value = $crossSite; } else { if ( in_array( $key, $keepLineReturnsFields ) ) { $value = preg_replace( '/\r\n/', '[==LINE_RETURN==]', $value ); $value = preg_replace( '/\n/', '[==LINE_RETURN==]', $value ); } $value = sanitize_text_field( $value ); if ( in_array( $key, $keepLineReturnsFields ) ) { $value = preg_replace( '/\[==LINE_RETURN==\]/', "\n", $value ); } } } // Sync upload params to ensure consistency // fileUpload is the master control - respect its value $fileUploadEnabled = !empty( $chatbot['fileUpload'] ) || !empty( $chatbot['imageUpload'] ); $maxFiles = isset( $chatbot['maxUploads'] ) && $chatbot['maxUploads'] > 0 ? (int) $chatbot['maxUploads'] : 1; $chatbot['imageUpload'] = $fileUploadEnabled; $chatbot['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0; $chatbot['multiUpload'] = $fileUploadEnabled && $maxFiles > 1; } update_option( $this->chatbots_option_name, $chatbots ); // update_option() also returns false when nothing changed, so it can't tell us whether the // save worked. Read the row back from the database instead: a wp_options table that isn't // utf8mb4 refuses a chatbot with an emoji, and the admin used to show it as saved anyway. $this->forget_cached_options( $this->chatbots_option_name ); $saved = get_option( $this->chatbots_option_name, [] ); if ( $saved != $chatbots ) { Meow_MWAI_Logging::warn( 'The chatbots read back from the database differ from the ones just saved.' ); throw new Exception( __( 'The chatbots could not be saved: the database did not keep them as sent. This usually happens when a chatbot contains emojis and the wp_options table does not use the utf8mb4 charset.', 'ai-engine' ) ); } return $saved; } public function populate_dynamic_options( $options ) { static $populating = false; // Prevent infinite recursion if ( $populating ) { return $options; } $populating = true; // Languages - use custom languages as the complete list $custom_languages = isset( $options['custom_languages'] ) && !empty( $options['custom_languages'] ) ? $options['custom_languages'] : []; // If no custom languages defined, fall back to defaults if ( empty( $custom_languages ) ) { $options['languages'] = apply_filters( 'mwai_languages', MWAI_LANGUAGES ); } else { // Process custom languages $processed_languages = []; foreach ( $custom_languages as $custom_lang ) { // Support formats like "Russian (ru)" or just "Russian" $custom_lang = trim( $custom_lang ); if ( !empty( $custom_lang ) ) { // Check if language code is provided in parentheses if ( preg_match( '/^(.+)\s*\(([a-z]{2,3})\)$/i', $custom_lang, $matches ) ) { $lang_name = trim( $matches[1] ); $lang_code = strtolower( trim( $matches[2] ) ); $processed_languages[$lang_code] = $lang_name; } else { // No code provided, add as-is $processed_languages[] = $custom_lang; } } } $options['languages'] = apply_filters( 'mwai_languages', $processed_languages ); } // Consolidate the Engines and their Models // PS: We should ABSOLUTELY AVOID to use ai_models directly (except for saving) // Engine Example: [ 'name' => 'Ollama', 'type' => 'ollama', inputs => ['apikey', 'endpoint'], models => [] ] $engines = MWAI_ENGINES; // Partner providers stay hidden until the partnership is live (see is_partner_provider_enabled()). // A site that already has an environment of that type keeps seeing it, so saved data (or an // environment a host provisioned for its customer) still works. $envTypes = array_column( $options['ai_envs'] ?? [], 'type' ); foreach ( [ 'ovh' => 'OVHcloud', 'sakana' => 'Sakana AI' ] as $type => $name ) { if ( $this->is_partner_provider_enabled( $type ) || in_array( $type, $envTypes, true ) ) { $engines[] = [ 'name' => $name, 'type' => $type, 'inputs' => [ 'apikey', 'dynamicModels' ], 'internal' => true, 'models' => [], ]; } } $options['ai_engines'] = apply_filters( 'mwai_engines', $engines ); foreach ( $options['ai_engines'] as &$engine ) { if ( $engine['type'] === 'openai' ) { $engine['models'] = apply_filters( 'mwai_openai_models', Meow_MWAI_Engines_OpenAI::get_models_static() ); } else if ( $engine['type'] === 'anthropic' ) { $engine['models'] = apply_filters( 'mwai_anthropic_models', Meow_MWAI_Engines_Anthropic::get_models_static() ); } else if ( $engine['type'] === 'perplexity' ) { $engine['models'] = apply_filters( 'mwai_perplexity_models', Meow_MWAI_Engines_Perplexity::get_models_static() ); } else if ( $engine['type'] === 'mistral' ) { $engine['models'] = apply_filters( 'mwai_mistral_models', Meow_MWAI_Engines_Mistral::get_models_static() ); } else if ( $engine['type'] === 'xai' ) { // Static fallback covers the case where dynamic model fetch failed (e.g. no credits // on the xAI account). Dynamically fetched models override this list when available. $engine['models'] = apply_filters( 'mwai_xai_models', Meow_MWAI_Engines_XAI::get_models_static() ); } else if ( $engine['type'] === 'sakana' ) { $engine['models'] = apply_filters( 'mwai_sakana_models', Meow_MWAI_Engines_Sakana::get_models_static() ); } else { $engine['models'] = []; foreach ( $options['ai_models'] as $model ) { if ( $model['type'] === $engine['type'] ) { $engine['models'][] = $model; } } } } // Functions via Code Engine (or custom code) $json = []; $functions = apply_filters( 'mwai_functions_list', [] ); foreach ( $functions as $function ) { if ( $function->type === 'editor-assistant' ) { continue; } $json[] = Meow_MWAI_Query_Function::toJson( $function ); } $options['functions'] = $json; // Addons $options['addons'] = apply_filters( 'mwai_addons', [ [ 'slug' => 'mwai-notifications', 'name' => 'Notifications', 'description' => 'Get real-time alerts for new discussions in your chatbot, so you never miss a chance to engage.', 'install_url' => 'https://meowapps.com/products/mwai-notifications/', 'settings_url' => null, 'stars' => 4, 'enabled' => false ], [ 'slug' => 'mwai-ollama', 'name' => 'Ollama', 'description' => 'Leverage local LLM integration through Ollama; refresh and use your own models for a flexible, cost-free approach.', 'install_url' => 'https://meowapps.com/products/mwai-ollama/', 'settings_url' => null, 'stars' => 3, 'enabled' => false ], [ 'slug' => 'mwai-deepseek', 'name' => 'DeepSeek', 'description' => 'Support for DeepSeek, a Chinese AI company that provides extremely powerful LLM models.', 'install_url' => 'https://meowapps.com/products/deepseek/', 'settings_url' => null, 'stars' => 3, 'enabled' => false ], [ 'slug' => 'mwai-websearch', 'name' => 'Web Search', 'description' => 'Enhance chatbot responses by pulling context from Google and Tavily, delivering more accurate answers.', 'install_url' => 'https://meowapps.com/products/mwai-websearch/', 'settings_url' => null, 'stars' => 5, 'enabled' => false ], [ 'slug' => 'mwai-better-links', 'name' => 'Better Links', 'description' => 'Validate internal and external links and map specific terms to custom URLs, ensuring smoother navigation and references.', 'install_url' => 'https://meowapps.com/products/mwai-better-links/', 'settings_url' => null, 'stars' => 3, 'enabled' => false ], [ 'slug' => 'mwai-woo-basics', 'name' => 'Woo Basics', 'description' => 'Access essential WooCommerce data so your chatbot can understand products, orders, and more for a richer shopping experience.', 'install_url' => 'https://meowapps.com/products/mwai-woo-basics/', 'settings_url' => null, 'stars' => 2, 'enabled' => false ], [ 'slug' => 'mwai-quick-actions', 'name' => 'Quick Actions', 'description' => 'Enable dynamic quick actions at chat start or during events, helping users find what they need faster.', 'install_url' => 'https://meowapps.com/products/mwai-quick-actions/', 'settings_url' => null, 'stars' => 3, 'enabled' => false ], [ 'slug' => 'mwai-content-parser', 'name' => 'Content Parser', 'description' => 'Parse complex website content, including ACF fields and page builders, for more precise embeddings and knowledge retrieval.', 'install_url' => 'https://meowapps.com/products/mwai-content-parser/', 'settings_url' => null, 'stars' => 2, 'enabled' => false ], [ 'slug' => 'mwai-visitor-form', 'name' => 'Visitor Form', 'description' => 'Add a customizable form triggered by specific events in your chatbot to collect key visitor information seamlessly.', 'install_url' => 'https://meowapps.com/products/mwai-visitor-form/', 'settings_url' => null, 'stars' => 2, 'enabled' => false ], [ 'slug' => 'mwai-dynamic-keys', 'name' => 'Dynamic Keys', 'description' => 'Rotate multiple API keys dynamically for any environment, balancing usage and ensuring smooth performance.', 'install_url' => 'https://meowapps.com/products/mwai-dynamic-keys/', 'settings_url' => null, 'stars' => 1, 'enabled' => false ], [ 'slug' => 'mwai-user-memory', 'name' => 'User Memory', 'description' => 'Let your chatbot remember details about logged-in users across conversations, for more personal and context-aware replies.', 'install_url' => 'https://meowapps.com/products/mwai-user-memory/', 'settings_url' => null, 'stars' => 3, 'enabled' => false ], ] ); // Add-ons mark themselves enabled through the `mwai_addons` filter, but they // only register that hook inside is_admin(). Over REST (the settings/options // endpoint, or an options auto-save) is_admin() is false, so none of them would // report as enabled and the admin UI would show no enabled add-ons. Flag any // add-on whose plugin is active as a context-independent fallback. if ( !function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( $options['addons'] as &$addon ) { if ( empty( $addon['enabled'] ) && is_plugin_active( $addon['slug'] . '/' . $addon['slug'] . '.php' ) ) { $addon['enabled'] = true; } } unset( $addon ); // SEO stats. Filled by SEO Engine when it is active; null otherwise. AI Engine knows // nothing about SEO Engine's internals, exactly like the addons list above. // Admin-only: this feeds the Modules tab, and the provider filter may run SQL, so it // must never fire on guest-facing requests (get_option() rebuilds this payload often). // Guard on wp_get_current_user, not current_user_can: the core is constructed while // plugins load, before pluggable.php exists, and current_user_can calls into it. $can_admin = function_exists( 'wp_get_current_user' ) && current_user_can( 'manage_options' ); $options['seo_stats'] = $can_admin ? apply_filters( 'mwai_seo_stats', null ) : null; $options['seo_robots'] = $can_admin ? $this->get_ai_crawler_access() : null; // WordPress's own page for SEO Engine, which offers Install (or Activate). Only for users // who may install plugins; the dashboard falls back to the wordpress.org page otherwise. $options['seo_install_url'] = $can_admin && current_user_can( 'install_plugins' ) ? self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=seo-engine' ) : null; // Populate usage data from ai_usage to ai_models_usage for the frontend $ai_usage = $this->get_option( 'ai_usage', [] ); $options['ai_models_usage'] = $ai_usage; // Also include daily usage data $ai_usage_daily = $this->get_option( 'ai_usage_daily', [] ); $options['ai_models_usage_daily'] = $ai_usage_daily; $populating = false; return $options; } // Reports whether the well-known AI crawlers are allowed by robots.txt. Local reads only: // fetching home_url( '/robots.txt' ) over HTTP would be slow on an admin screen and fails // on hosts that block loopback requests. public function get_ai_crawler_access() { // Nothing before WordPress has finished loading. This is reached from // populate_dynamic_options(), which runs while plugins are still being included, and the // robots_txt filter further down hands control to whatever other plugins hooked it. One of // them (XML Sitemap & Google News) calls $wp_rewrite->using_permalinks(), and $wp_rewrite // does not exist that early, so the whole site died with a fatal, front end included. // Every screen that shows this value renders long after wp_loaded, and the value is // recomputed on each get_all_options() call, so nothing is lost by waiting. if ( !did_action( 'wp_loaded' ) ) { return null; } $public = get_option( 'blog_public' ); $discouraged = (string) $public === '0'; $has_file = file_exists( ABSPATH . 'robots.txt' ); $mtime = $has_file ? ( filemtime( ABSPATH . 'robots.txt' ) ?: null ) : null; // The cache is only trusted while its cheap fingerprints still match, so editing // robots.txt (SEO plugin, FTP) or toggling "Discourage search engines" shows up // immediately instead of after the transient expires. $cached = get_transient( 'mwai_ai_crawler_access' ); if ( is_array( $cached ) && array_key_exists( 'mtime', $cached ) && $cached['mtime'] === $mtime && $cached['discouraged'] === $discouraged ) { return $cached; } $bots = [ 'GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'CCBot', 'Applebot-Extended', 'meta-externalagent', 'Bytespider' ]; $source = 'virtual'; $content = ''; if ( $has_file ) { $source = 'file'; $content = (string) file_get_contents( ABSPATH . 'robots.txt' ); } else { // WordPress only serves a virtual robots.txt when no physical file exists. Rebuild // what it actually serves (do_robots() echoes, so replicate its default) and run the // robots_txt filter so SEO plugins hooking it are reflected. $site_url = wp_parse_url( site_url() ); $path = !empty( $site_url['path'] ) ? $site_url['path'] : ''; $default = "User-agent: *\n"; $default .= "Disallow: $path/wp-admin/\n"; $default .= "Allow: $path/wp-admin/admin-ajax.php\n"; $content = apply_filters( 'robots_txt', $default, $public ); } $access = [ 'discouraged' => $discouraged, 'source' => $source, 'mtime' => $mtime, 'bots' => $this->parse_robots_for_bots( $content, $bots, $discouraged ), ]; set_transient( 'mwai_ai_crawler_access', $access, HOUR_IN_SECONDS ); return $access; } private function parse_robots_for_bots( $content, $bots, $discouraged ) { $access = []; // Settings > Reading > "Discourage search engines" overrides everything. if ( $discouraged ) { foreach ( $bots as $bot ) { $access[$bot] = 'blocked'; } return $access; } // Group the rules: consecutive User-agent lines share the record that follows. $groups = []; $agents = []; $collecting = true; $lines = preg_split( '/\r\n|\r|\n/', $content ); foreach ( $lines as $line ) { $line = trim( preg_replace( '/#.*$/', '', $line ) ); if ( $line === '' ) { continue; } if ( preg_match( '/^user-agent\s*:\s*(.+)$/i', $line, $m ) ) { if ( !$collecting ) { $agents = []; $collecting = true; } $agent = strtolower( trim( $m[1] ) ); $agents[] = $agent; // Register the group even if it only ever gets Allow lines, so a bot with its // own (permissive) group never falls back to a blocking * group. if ( !isset( $groups[$agent] ) ) { $groups[$agent] = []; } } else if ( preg_match( '/^disallow\s*:\s*(.*)$/i', $line, $m ) ) { $collecting = false; $rule = trim( $m[1] ); foreach ( $agents as $agent ) { $groups[$agent][] = $rule; } } else { // Allow, Sitemap, Crawl-delay… end the User-agent run but keep the group. $collecting = false; } } foreach ( $bots as $bot ) { $key = strtolower( $bot ); $rules = isset( $groups[$key] ) ? $groups[$key] : ( isset( $groups['*'] ) ? $groups['*'] : [] ); // Only a full "Disallow: /" counts as blocked; narrower paths leave the site readable. $access[$bot] = in_array( '/', $rules, true ) ? 'blocked' : 'allowed'; } return $access; } public function get_all_options( $force = false, $sanitize = false ) { if ( $force ) { $this->forget_cached_options(); } if ( $force || is_null( $this->options ) ) { $options = get_option( $this->option_name, [] ); $init_mode = empty( $options ); $stored_mcp = is_array( $options ) && isset( $options['module_mcp'] ); $stored_approvals = is_array( $options ) && isset( $options['mcp_approvals'] ); foreach ( MWAI_OPTIONS as $key => $value ) { if ( !isset( $options[$key] ) ) { $options[$key] = $value; } } // MCP is on by default for new installs only (3.8.8). A site that already had options but // never stored this key had it off: an update must never open the MCP endpoint by itself. if ( !$init_mode && !$stored_mcp ) { $options['module_mcp'] = false; } // Same for approvals: new installs ask before deletes, an updated site keeps running as it did // (scripts on the bearer token would otherwise stop at the first delete). if ( !$init_mode && !$stored_approvals ) { $options['mcp_approvals'] = 'off'; } $options['chatbot_defaults'] = MWAI_CHATBOT_DEFAULT_PARAMS; $options['default_limits'] = MWAI_LIMITS; // Force sanitization if custom_languages is not set (migration) $needs_language_migration = !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ); if ( $sanitize || $init_mode || $needs_language_migration ) { $options = $this->sanitize_options( $options ); } $this->options = $options; } $options = $this->populate_dynamic_options( $this->options ); $options['chatbot_defaults'] = $this->get_chatbot_default_params(); return $options; } // Sanitize options when we update the plugin or perform some updates // if we change the structure of the options. public function sanitize_options( $options ) { $needs_update = false; // Removing old options of options renaming should be done here, as it was done before. // Check version 2.6.8 for an example. // Avoid the logs_path to be a PHP file. if ( isset( $options['logs_path'] ) ) { $logs_path = $options['logs_path']; if ( substr( $logs_path, -4 ) !== '.log' ) { $options['logs_path'] = ''; $needs_update = true; } } // The IDs for the embeddings environments are generated here. // TODO: We should handle this more gracefully via an option in the Embeddings Settings. $embeddings_default_exists = false; if ( isset( $options['embeddings_envs'] ) ) { foreach ( $options['embeddings_envs'] as &$env ) { if ( !isset( $env['id'] ) ) { $env['id'] = $this->get_random_id(); $needs_update = true; } if ( $env['id'] === $options['embeddings_default_env'] ) { $embeddings_default_exists = true; } } } if ( !$embeddings_default_exists ) { $options['embeddings_default_env'] = $options['embeddings_envs'][0]['id'] ?? null; $needs_update = true; } // The IDs for the AI environments are generated here. $allEnvIds = []; $ai_default_exists = false; // Allow empty string as valid "None" selection $ai_default_is_none = isset( $options['ai_default_env'] ) && $options['ai_default_env'] === ''; if ( isset( $options['ai_envs'] ) ) { foreach ( $options['ai_envs'] as &$env ) { if ( !isset( $env['id'] ) ) { $env['id'] = $this->get_random_id(); $needs_update = true; } if ( $env['id'] === $options['ai_default_env'] ) { $ai_default_exists = true; } $allEnvIds[] = $env['id']; } } if ( !$ai_default_exists && !$ai_default_is_none ) { $options['ai_default_env'] = $options['ai_envs'][0]['id'] ?? null; $needs_update = true; } // The IDs for the MCP environments are generated here. if ( isset( $options['mcp_envs'] ) ) { foreach ( $options['mcp_envs'] as &$env ) { if ( !isset( $env['id'] ) ) { $env['id'] = $this->get_random_id(); $needs_update = true; } } } // Migration: DALL-E was removed (deprecated by OpenAI). Move users to the image fallback. // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12). if ( isset( $options['ai_images_default_model'] ) && in_array( $options['ai_images_default_model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) { $options['ai_images_default_model'] = MWAI_FALLBACK_MODEL_IMAGES; $needs_update = true; } // All the models with an envId that does not exist anymore are removed. if ( isset( $options['ai_models'] ) ) { $options['ai_models'] = array_values( array_filter( $options['ai_models'], function ( $model ) use ( $allEnvIds, &$needs_update ) { if ( isset( $model['envId'] ) && !in_array( $model['envId'], $allEnvIds ) ) { $needs_update = true; return false; } return true; } ) ); } // Migration: limits.creditType 'units' → 'tokens'. The read path in // premium/statistics.php still accepts both, so this is purely cosmetic // alignment with the new "Tokens" labels. One-shot per install. // TODO: Remove after 2026-11. if ( isset( $options['limits'] ) && is_array( $options['limits'] ) ) { foreach ( [ 'system', 'users', 'guests' ] as $bucket ) { if ( isset( $options['limits'][$bucket]['creditType'] ) && $options['limits'][$bucket]['creditType'] === 'units' ) { $options['limits'][$bucket]['creditType'] = 'tokens'; $needs_update = true; } } } // Migration: Populate custom_languages if empty for existing installations if ( !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ) ) { $options['custom_languages'] = [ 'English (en)', 'German (de)', 'French (fr)', 'Spanish (es)', 'Italian (it)', 'Chinese (zh)', 'Japanese (ja)', 'Portuguese (pt)' ]; $needs_update = true; } if ( $needs_update ) { ksort( $options ); update_option( $this->option_name, $options, false ); } return $options; } public function update_options( $options ) { // The state before the day's first change is kept, so any save can be undone (Maintenance). if ( $this->settingsBackups ) { $this->settingsBackups->maybe_snapshot( 'before-change' ); } // update_option returns false both when update fails AND when value is unchanged // We just attempt the update and always return the current options // The frontend will see if the values actually changed update_option( $this->option_name, $options, false ); $options = $this->get_all_options( true, true ); return $options; } // Saves only the keys that changed, merged into a fresh read of what is stored. The full save // posted every option in one request (300 KB and more with long prompts), and some hosts // (mod_security, united-domains) answered a bare 400 before WordPress saw it, so nothing could // be saved, MCP could not even be turned on. Reading fresh also keeps a change made elsewhere // in the meantime instead of overwriting it with this tab's older copy. public function patch_options( array $changes ) { $this->forget_cached_options(); $stored = get_option( $this->option_name, [] ); $stored = is_array( $stored ) ? $stored : []; foreach ( $changes as $key => $value ) { $stored[ $key ] = $value; } return $this->update_options( $stored ); } // A forced read must come from the database. WordPress keeps an option in memory for the // whole request, so a long request (a streaming reply, a Knowledge sync) re-read the copy it // loaded when it started, then saved it back with its usage stats: any setting changed in the // meantime was silently undone (a refreshed Gemini model list went from 33 back to 25). private function forget_cached_options( $option_name = null ) { $option_name = $option_name ?? $this->option_name; wp_cache_delete( $option_name, 'options' ); $alloptions = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $alloptions ) && isset( $alloptions[ $option_name ] ) ) { unset( $alloptions[ $option_name ] ); wp_cache_set( 'alloptions', $alloptions, 'options' ); } } public function update_option( $option, $value ) { $options = $this->get_all_options( true ); $options[$option] = $value; return $this->update_options( $options ); } // Turns on the MCP module and nothing else: a raw read and write of the stored option, since // update_option() saves get_all_options() (defaults, dynamic values) back with it. public function enable_mcp_module() { $options = get_option( $this->option_name, [] ); if ( !is_array( $options ) ) { $options = []; } if ( empty( $options['module_mcp'] ) ) { $options['module_mcp'] = true; update_option( $this->option_name, $options, false ); $this->get_all_options( true ); } } // Dashboard cards an admin closed for good, per user. public function get_dismissed_dashboard_cards( $user_id = null ) { $dismissed = get_user_meta( $user_id ?: get_current_user_id(), 'mwai_dismissed_cards', true ); return is_array( $dismissed ) ? array_values( $dismissed ) : []; } public function dismiss_dashboard_card( $card, $user_id = null ) { $user_id = $user_id ?: get_current_user_id(); $dismissed = $this->get_dismissed_dashboard_cards( $user_id ); if ( !in_array( $card, $dismissed, true ) ) { $dismissed[] = $card; update_user_meta( $user_id, 'mwai_dismissed_cards', $dismissed ); } return $dismissed; } /** * Sakana AI is offered as a provider only once the partnership is confirmed. Turn it on * with define( 'MWAI_SAKANA', true ) in wp-config.php or the mwai_sakana_enabled filter. */ // Turned on with define( 'MWAI_OVH', true ) or define( 'MWAI_SAKANA', true ) in wp-config.php, // or the mwai_ovh_enabled / mwai_sakana_enabled filter. Dev Mode used to reveal OVHcloud, but // any user can switch Dev Mode on, so it was not really hidden. public function is_partner_provider_enabled( $type ) { $constant = 'MWAI_' . strtoupper( $type ); return (bool) apply_filters( "mwai_{$type}_enabled", defined( $constant ) && constant( $constant ) ); } public function get_option( $option, $default = null ) { $options = $this->get_all_options(); return $options[$option] ?? $default; } public function update_ai_env( $env_id, $option, $value ) { $options = $this->get_all_options( true ); foreach ( $options['ai_envs'] as &$env ) { if ( $env['id'] === $env_id ) { $env[$option] = $value; break; } } return $this->update_options( $options ); } public function get_engine_models( $engineType ) { // This method is called by engines with just a string type // We need to get the models differently $options = $this->get_all_options(); $engines = $options['ai_envs']; $models = []; // Find all models for this engine type foreach ( $engines as $engine ) { if ( $engine['type'] === $engineType ) { if ( isset( $engine['models'] ) ) { foreach ( $engine['models'] as $model ) { $models[] = $model; } } } } // Also check custom models if ( isset( $options['ai_models'] ) ) { foreach ( $options['ai_models'] as $model ) { if ( $model['type'] === $engineType ) { $models[] = $model; } } } return $models; } public function reset_options() { delete_option( $this->themes_option_name ); delete_option( $this->chatbots_option_name ); delete_option( $this->option_name ); return $this->get_all_options( true ); } #endregion #region Cron Tracking public function track_cron_start( $hook ) { // Set running transient (expires in 5 minutes as a safety measure) set_transient( 'mwai_cron_running_' . $hook, true, 300 ); } public function track_cron_end( $hook, $status = 'success', $error_message = '' ) { // Remove running transient delete_transient( 'mwai_cron_running_' . $hook ); // Get existing data $cron_data = get_transient( 'mwai_cron_last_run' ) ?: []; // Update this cron's data - use time() for consistency $cron_data[$hook] = [ 'time' => time(), 'status' => $status, 'error' => $error_message ]; // Store for 7 days set_transient( 'mwai_cron_last_run', $cron_data, 7 * DAY_IN_SECONDS ); } #endregion }