core = $mwai_core; $this->siteWideChatId = $this->core->get_option( 'botId' ); add_shortcode( 'mwai_chatbot', [ $this, 'chat_shortcode' ] ); // Blocks render at priority 9 and shortcodes at 11, so these wrap both. add_filter( 'the_content', [ $this, 'content_render_start' ], 0 ); add_filter( 'the_content', [ $this, 'content_render_end' ], PHP_INT_MAX ); add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); // Late, so every shortcode in the content and the site-wide injection have all run. add_action( 'wp_footer', [ $this, 'warn_about_stacked_popups' ], 99 ); add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'register_scripts' ] ); if ( $this->core->get_option( 'chatbot_discussions' ) ) { add_shortcode( 'mwai_discussions', [ $this, 'chatbot_discussions' ] ); } } public function register_scripts() { // Load JS $physical_file = trailingslashit( MWAI_PATH ) . 'app/chatbot.js'; $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION; wp_register_script( 'mwai_chatbot', trailingslashit( MWAI_URL ) . 'app/chatbot.js', [ 'wp-element' ], $cache_buster, false ); // Actual loading of the scripts $hasSiteWideChat = $this->siteWideChatId && $this->siteWideChatId !== 'none'; if ( is_admin() ) { // In the admin, the chatbot widget and its theme CSS are only needed for the // preview on AI Engine's own screens. Loading them on every admin page pushed // the frontend theme stylesheets into the block-editor iframe, which WordPress // warns about ("added to the iframe incorrectly"), so scope them to our pages. $current_screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $is_ai_engine_page = $current_screen && strpos( $current_screen->id, 'mwai' ) !== false; if ( $is_ai_engine_page ) { $this->enqueue_scripts( null ); } return; } if ( $hasSiteWideChat ) { $bot = $this->core->get_chatbot( $this->siteWideChatId ); $themeId = ( $bot && isset( $bot['themeId'] ) ) ? $bot['themeId'] : null; $this->enqueue_scripts( $themeId ); // Chatbot Injection add_action( 'wp_footer', [ $this, 'inject_chat' ] ); } } public function enqueue_scripts( $themeId = null ) { wp_enqueue_script( 'mwai_chatbot' ); if ( $this->core->get_option( 'syntax_highlight' ) ) { wp_enqueue_script( 'mwai_highlight' ); } if ( $themeId ) { $this->core->enqueue_theme( $themeId ); } else { $this->core->enqueue_themes(); } } /** * Helper method to create REST responses with automatic token refresh * * @param array $data The response data * @param int $status HTTP status code * @return WP_REST_Response */ protected function create_rest_response( $data, $status = 200 ) { // Always check if we need to provide a new nonce $current_nonce = $this->core->get_nonce( true ); $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null; // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours) // We'll refresh if the nonce is older than 10 hours to be safe $should_refresh = false; if ( $request_nonce ) { // Try to determine the age of the nonce // WordPress uses a tick system where each tick is 12 hours // If we're in the second half of the nonce's life, refresh it $time = time(); $nonce_tick = wp_nonce_tick(); // Verify if the nonce is still valid but getting old $verify = wp_verify_nonce( $request_nonce, 'wp_rest' ); if ( $verify === 2 ) { // Nonce is valid but was generated 12-24 hours ago $should_refresh = true; // Log will be written when token is included in response } } // If the nonce has changed or should be refreshed, include the new one if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) { $data['new_token'] = $current_nonce; // Log if server debug mode is enabled if ( $this->core->get_option( 'server_debug_mode' ) ) { error_log( '[AI Engine] Token refresh: Nonce refreshed (12-24 hours old)' ); } } return new WP_REST_Response( $data, $status ); } public function rest_api_init() { register_rest_route( $this->namespace, '/chats/submit', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_chat' ], 'permission_callback' => [ $this->core, 'check_rest_nonce' ] ] ); } public function basics_security_check( $botId, $customId, $newMessage, $newFileId, $newFileIds = [] ) { if ( !$botId && !$customId ) { Meow_MWAI_Logging::warn( 'The query was rejected - no botId nor id was specified.' ); return false; } // An empty message is acceptable when files are attached (single or multi upload). if ( $newFileId || !empty( $newFileIds ) ) { return true; } // Handle null or convert to string for strlen $messageStr = $newMessage === null ? '' : (string) $newMessage; $length = strlen( $messageStr ); if ( $length < 1 ) { Meow_MWAI_Logging::warn( 'The query was rejected - message was too short.' ); return false; } return true; } public function build_final_res( $botId, $newMessage, $newFileId, $params, $reply, $images, $actions, $usage, $responseId = null, $resetResponseId = false ) { $filterParams = [ 'step' => 'reply', 'botId' => $botId, 'reply' => $reply, 'images' => $images, 'newMessage' => $newMessage, 'newFileId' => $newFileId, 'params' => $params, 'usage' => $usage, 'messages' => $params['messages'] ?? [], 'isNewConversation' => empty( $params['messages'] ) || count( $params['messages'] ) <= 1, ]; $actions = apply_filters( 'mwai_chatbot_actions', $actions, $filterParams ); $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams ); $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams ); $actions = $this->sanitize_actions( $actions ); $blocks = $this->sanitize_blocks( $blocks ); $shortcuts = $this->sanitize_shortcuts( $shortcuts ); $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId ); $result = [ 'success' => true, 'reply' => $reply, 'images' => $images, 'actions' => $actions, 'shortcuts' => $shortcuts, 'blocks' => $blocks, 'usage' => $usage ]; // Add response ID if available if ( !empty( $responseId ) ) { $result['responseId'] = $responseId; } // Tell the client to forget the id it was chaining on. An absent responseId is not // enough of a signal: the client keeps its previous one, which is a valid id, so the // provider would accept it and silently resume from BEFORE this turn, losing the // user's last message from the model's view. This flag is explicit for that reason. else if ( $resetResponseId ) { $result['resetResponseId'] = true; } // Check if token needs refresh $current_nonce = $this->core->get_nonce( true ); $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null; $should_refresh = false; if ( $request_nonce ) { $verify = wp_verify_nonce( $request_nonce, 'wp_rest' ); if ( $verify === 2 ) { // Nonce is valid but was generated 12-24 hours ago $should_refresh = true; } } if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) { $result['new_token'] = $current_nonce; } return $result; } public function rest_chat( $request ) { $params = $request->get_json_params(); $botId = $params['botId'] ?? null; $customId = $params['customId'] ?? null; $stream = $params['stream'] ?? false; $newMessage = trim( $params['newMessage'] ?? '' ); $newFileId = $params['newFileId'] ?? null; $newFileIds = $params['newFileIds'] ?? []; $crossSite = $params['crossSite'] ?? false; $shortcutId = $params['shortcutId'] ?? null; // If shortcutId is provided, look up the actual message if ( $shortcutId && empty( $newMessage ) ) { $shortcutMessage = $this->get_shortcut_message( $shortcutId, $botId ); if ( $shortcutMessage ) { $newMessage = $shortcutMessage; } else { return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid or expired shortcut.' ], 400 ); } } if ( !$this->basics_security_check( $botId, $customId, $newMessage, $newFileId, $newFileIds ) ) { return $this->create_rest_response( [ 'success' => false, 'message' => apply_filters( 'mwai_ai_exception', 'Sorry, your query has been rejected.' ) ], 403 ); } try { $data = $this->chat_submit( $botId, $newMessage, $newFileId, $params, $stream, $newFileIds ); $final_res = $this->build_final_res( $botId, $newMessage, $newFileId, $params, $data['reply'], $data['images'], $data['actions'], $data['usage'], $data['responseId'] ?? null, !empty( $data['resetResponseId'] ) ); // A paused turn waiting for a tool approval (Workspace WordPress Tools). if ( !empty( $data['approval'] ) ) { $final_res['approval'] = $data['approval']; } return $this->create_rest_response( $final_res, 200 ); } catch ( Exception $e ) { $message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); // Refusals (limits, security) carry a user-facing message; overLimit lets // the frontend lock the input instead of accepting doomed messages. $isRefusal = $e instanceof Meow_MWAI_RefusedException; $overLimit = $isRefusal && $e->reason === 'limits'; // If we're in streaming mode, send the error through the stream if ( $stream ) { // Log the error error_log( '[AI Engine Chatbot Error] ' . Meow_MWAI_Core::log_safe( $e->getMessage() ) . ' in ' . $e->getFile() . ':' . $e->getLine() ); // Send error event through stream. Internal errors stay generic for // visitors; a refusal message is meant for them, so pass it through. // Admins get the real error (they can act on it, e.g. in the Workspace). $errorData = [ 'type' => 'error', 'data' => ( $isRefusal || current_user_can( 'manage_options' ) ) ? $message : Meow_MWAI_Core::get_public_error_message( $e ), 'overLimit' => $overLimit ]; echo 'data: ' . json_encode( $errorData ) . "\n\n"; if ( ob_get_level() > 0 ) { ob_end_flush(); } flush(); die(); } // For non-streaming, same rule as the streaming branch above: a refusal is // written for the visitor and passes through, but an internal error (a // provider failure, a function-call loop, etc.) must not leak its details // to visitors. Mask it to a generic message for non-admins (the real one // is logged); admins still see it so they can debug. if ( !$isRefusal && !current_user_can( 'manage_options' ) ) { error_log( '[AI Engine Chatbot Error] ' . Meow_MWAI_Core::log_safe( $e->getMessage() ) . ' in ' . $e->getFile() . ':' . $e->getLine() ); $message = Meow_MWAI_Core::get_public_error_message( $e ); } return $this->create_rest_response( [ 'success' => false, 'message' => $message, 'overLimit' => $overLimit ], $overLimit ? 429 : 500 ); } } private function sanitize_items( $items, $supported_types, $type_name ) { if ( empty( $items ) ) { return $items; } $sanitized_items = []; foreach ( $items as $item ) { if ( isset( $supported_types[$item['type']] ) ) { $is_valid = true; foreach ( $supported_types[$item['type']] as $param ) { if ( !isset( $item['data'][$param] ) ) { $is_valid = false; Meow_MWAI_Logging::warn( "The query was rejected - missing required parameter '{$param}' for {$type_name} type: {$item['type']}." ); break; } } if ( $is_valid ) { $sanitized_items[] = $item; } } else { Meow_MWAI_Logging::warn( "The query was rejected - unsupported {$type_name} type: {$item['type']}." ); } } return $sanitized_items; } public function sanitize_actions( $actions ) { $supported_action_types = [ 'function' => ['name', 'args'], 'javascript' => ['snippet'], ]; return $this->sanitize_items( $actions, $supported_action_types, 'action' ); } public function sanitize_blocks( $blocks ) { $supported_block_types = [ 'content' => ['html'], ]; return $this->sanitize_items( $blocks, $supported_block_types, 'block' ); } public function sanitize_shortcuts( $shortcuts ) { $supported_shortcut_types = [ 'message' => ['label', 'message'], 'action' => ['label', 'message', 'action'], 'callback' => ['label', 'onClick'], ]; return $this->sanitize_items( $shortcuts, $supported_shortcut_types, 'shortcut' ); } /** * Get the encryption key derived from WordPress salts. * * @return string The 32-byte encryption key. */ private function get_shortcut_encryption_key() { return substr( hash( 'sha256', wp_salt( 'auth' ) . 'mwai_shortcuts' ), 0, 32 ); } /** * Encrypt shortcut data for safe transmission to the client. * * @param array $data The data to encrypt (message, botId). * @return string|null The base64-encoded encrypted payload, or null on failure. */ private function encrypt_shortcut_data( $data ) { $key = $this->get_shortcut_encryption_key(); $iv = openssl_random_pseudo_bytes( 16 ); $json = json_encode( $data ); $encrypted = openssl_encrypt( $json, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); if ( $encrypted === false ) { return null; } return base64_encode( $iv . $encrypted ); } /** * Decrypt shortcut data received from the client. * * @param string $payload The base64-encoded encrypted payload. * @return array|null The decrypted data, or null on failure. */ private function decrypt_shortcut_data( $payload ) { $key = $this->get_shortcut_encryption_key(); $decoded = base64_decode( $payload, true ); if ( $decoded === false || strlen( $decoded ) < 17 ) { return null; } $iv = substr( $decoded, 0, 16 ); $encrypted = substr( $decoded, 16 ); $decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); if ( $decrypted === false ) { return null; } return json_decode( $decrypted, true ); } /** * Prepare shortcuts for client by replacing messages with encrypted shortcutIds. * The messages are encrypted and can only be decrypted server-side. * This keeps the prompt content private and not exposed in the browser. * * @param array $shortcuts The shortcuts to prepare. * @param string $botId The bot ID for validation. * @return array The prepared shortcuts with encrypted shortcutIds instead of messages. */ public function prepare_shortcuts_for_client( $shortcuts, $botId ) { if ( empty( $shortcuts ) ) { return $shortcuts; } $prepared = []; foreach ( $shortcuts as $shortcut ) { $type = $shortcut['type'] ?? ''; $data = $shortcut['data'] ?? []; // Only process shortcuts that have a message (not callbacks) if ( isset( $data['message'] ) && !empty( $data['message'] ) ) { // Encrypt the message and botId $shortcutId = $this->encrypt_shortcut_data( [ 'message' => $data['message'], 'botId' => $botId, ] ); if ( $shortcutId ) { // Replace message with encrypted shortcutId unset( $data['message'] ); $data['shortcutId'] = $shortcutId; } } $prepared[] = [ 'type' => $type, 'data' => $data, ]; } return $prepared; } /** * Decrypt and retrieve a shortcut message from its encrypted ID. * * @param string $shortcutId The encrypted shortcut ID. * @param string $botId The bot ID for validation. * @return string|null The message, or null if decryption fails or botId mismatches. */ public function get_shortcut_message( $shortcutId, $botId ) { if ( empty( $shortcutId ) ) { return null; } $shortcut_data = $this->decrypt_shortcut_data( $shortcutId ); if ( !$shortcut_data || !isset( $shortcut_data['message'] ) ) { Meow_MWAI_Logging::warn( "Shortcut decryption failed for botId: {$botId}" ); return null; } // Validate botId matches (security check) if ( isset( $shortcut_data['botId'] ) && $shortcut_data['botId'] !== $botId ) { Meow_MWAI_Logging::warn( "Shortcut botId mismatch: expected {$shortcut_data['botId']}, got {$botId}" ); return null; } return $shortcut_data['message']; } #region Messages Integrity Check public function messages_integrity_diff( $messages1, $messages2 ) { // Ensure both parameters are arrays if ( !is_array( $messages1 ) ) { $messages1 = []; } if ( !is_array( $messages2 ) ) { $messages2 = []; } // Collect messages with role not 'user' from messages1 $messagesList1 = []; foreach ( $messages1 as $msg ) { $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null ); $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null ); if ( $role && $role != 'user' ) { $messageData = [ 'role' => $role, 'content' => $content ]; $messagesList1[] = $messageData; } } // Collect messages with role not 'user' from messages2 $messagesList2 = []; foreach ( $messages2 as $msg ) { $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null ); $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null ); if ( $role && $role != 'user' ) { $messageData = [ 'role' => $role, 'content' => $content ]; $messagesList2[] = $messageData; } } // Count occurrences of each message in messagesList1 $counts1 = []; foreach ( $messagesList1 as $msg ) { $key = serialize( $msg ); if ( isset( $counts1[ $key ] ) ) { $counts1[ $key ]++; } else { $counts1[ $key ] = 1; } } // Count occurrences of each message in messagesList2 $counts2 = []; foreach ( $messagesList2 as $msg ) { $key = serialize( $msg ); if ( isset( $counts2[ $key ] ) ) { $counts2[ $key ]++; } else { $counts2[ $key ] = 1; } } // Compare counts to find unmatched messages $all_keys = array_unique( array_merge( array_keys( $counts1 ), array_keys( $counts2 ) ) ); $diffs = []; foreach ( $all_keys as $key ) { $count1 = isset( $counts1[ $key ] ) ? $counts1[ $key ] : 0; $count2 = isset( $counts2[ $key ] ) ? $counts2[ $key ] : 0; if ( $count1 != $count2 ) { $message = unserialize( $key ); $diffs[] = [ 'message' => $message, 'count_in_messages1' => $count1, 'count_in_messages2' => $count2 ]; } } return $diffs; } private function calculate_messages_checksum( $messages ) { $messages_to_hash = []; foreach ( $messages as $msg ) { $role = is_array( $msg ) ? ( $msg['role'] ?? '' ) : ( is_object( $msg ) ? ( $msg->role ?? '' ) : '' ); $content = is_array( $msg ) ? ( $msg['content'] ?? '' ) : ( is_object( $msg ) ? ( $msg->content ?? '' ) : '' ); if ( in_array( $role, ['assistant', 'system'] ) ) { $messages_to_hash[] = [ 'role' => $role, 'content' => $content ]; } } return md5( json_encode( $messages_to_hash ) ); } #endregion public function chat_submit( $botId, $newMessage, $newFileId = null, $params = [], $stream = false, $newFileIds = [] ) { $query = null; // Initialize query variable to avoid undefined variable errors try { $chatbot = null; $customId = $params['customId'] ?? null; // Server params (model, envId, instructions, apiKey, tools, mcpServers...) // are configured server-side and only reach a request through the resolved // chatbot: a registered botId, or the customId transient that a shortcode // override stores. They must never be trusted from the request body, or a // hand-crafted call to this public endpoint could force an expensive model, // swap the environment (and its API key), or replace the system prompt on // someone else's site. Callers who can configure chatbots anyway (the // Workspace, admins) are exempt so their per-conversation overrides keep // working. See MWAI_CHATBOT_SERVER_PARAMS and the shortcode override path. // // This runs BEFORE the chatbot is resolved, and that ordering is the point. The // mwai_internal_chatbot filter below reads $params to build its chatbot, so when // the strip ran after it the Editor Assistant had already baked a caller-supplied // envId into the resolved chatbot and stripping $params afterwards was too late: // a guest could still pick which environment (and API key) paid for the query. // Only $customId is read before this point, and it is not a server param. $allowClientServerParams = apply_filters( 'mwai_chatbot_allow_client_server_params', $this->core->can_access_settings(), $botId, $params ); if ( !$allowClientServerParams && is_array( $params ) ) { $params = Meow_MWAI_Query_Base::client_params( $params, MWAI_CHATBOT_SERVER_PARAMS ); // The history in the body is display context, not a control channel. // build_messages() appends these verbatim right after the real system // prompt, so a 'system' (or 'developer'/'tool') turn smuggled in here // would re-open the prompt-override hole from a second door. Keep only // the user/assistant turns a real conversation is made of. if ( !empty( $params['messages'] ) && is_array( $params['messages'] ) ) { $params['messages'] = array_values( array_filter( $params['messages'], function ( $m ) { $role = is_array( $m ) ? ( $m['role'] ?? '' ) : ( is_object( $m ) ? ( $m->role ?? '' ) : '' ); return in_array( $role, [ 'user', 'assistant' ], true ); } ) ); } } // Custom Chatbot if ( $customId ) { $chatbot = get_transient( 'mwai_custom_chatbot_' . $customId ); } // Registered Chatbot if ( !$chatbot && $botId ) { $chatbot = $this->core->get_chatbot( $botId ); } // Internal Chatbots (reserved mwai_ prefix) if ( !$chatbot && $botId && strpos( $botId, 'mwai_' ) === 0 ) { $chatbot = apply_filters( 'mwai_internal_chatbot', null, $botId, $params ); } // Fall back to default chatbot if no chatbot found yet if ( !$chatbot ) { $chatbot = $this->core->get_chatbot( 'default' ); } if ( !$chatbot ) { Meow_MWAI_Logging::warn( 'The query was rejected - no chatbot was found.' ); throw new Exception( 'Sorry, your query has been rejected.' ); } $textInputMaxLength = $chatbot['textInputMaxLength'] ?? null; if ( $textInputMaxLength && $this->core->safe_strlen( $newMessage ) > (int) $textInputMaxLength ) { Meow_MWAI_Logging::warn( 'The query was rejected - message was too long.' ); throw new Exception( 'Sorry, your query has been rejected.' ); } // We need to check the integrity of the messages sent by the client. // This is important to ensure that the messages are not tampered with. // Messages Integrity Check with Checksums $chatId = $params['chatId'] ?? 'default'; $checksum_key = 'mwai_chatbot_checksum_' . $chatId; $stored_checksum = get_transient( $checksum_key ); $client_messages = $params['messages'] ?? []; $client_checksum = $this->calculate_messages_checksum( $client_messages ); if ( $stored_checksum && $stored_checksum !== $client_checksum ) { Meow_MWAI_Logging::warn( 'Integrity Check: Messages integrity check failed. Assistant or system messages sent by the client do not match stored messages. Please enable the Discussions module for better logs.' ); } // Messages Integrity Check with Discussions if ( $this->core->get_option( 'chatbot_discussions' ) && $this->core->discussions && isset( $params['chatId'] ) ) { $discussion = $this->core->discussions->get_discussion( $botId ? $botId : $customId, $params['chatId'] ); if ( $discussion ) { $messages = $discussion['messages']; $clientMessages = isset( $params['messages'] ) ? $params['messages'] : []; $diffs = $this->messages_integrity_diff( $messages, $clientMessages ); if ( count( $diffs ) > 0 ) { Meow_MWAI_Logging::warn( "Integrity Check: It seems the messages in the discussion #{$discussion['id']} do not match the ones sent by the client." ); } // Maintain conversation state for the Responses API by restoring the // stored response id when the client did not send one. Two things must // both hold, and both were wrong before: // 1. The keys. Discussions store previousResponseId / previousResponseDate // (see discussions.php), not responseId / responseDate, so this // restore silently never ran. // 2. Ownership. A response id resumes the whole conversation server-side // at the provider, so restoring it for a chatId that belongs to // someone else would replay their private conversation to whoever // supplies the chatId. get_discussion() is intentionally not // owner-scoped, so gate the restore on ownership here: the same // logged-in user, or the same guest session that created it. The // official UI already restores previousResponseId client-side, so // this stays a safe server-side fallback, not the primary path. if ( empty( $params['previousResponseId'] ) && !empty( $discussion['extra'] ) ) { $extra = json_decode( $discussion['extra'], true ); $storedResponseId = $extra['previousResponseId'] ?? null; if ( !empty( $storedResponseId ) ) { $currentUserId = get_current_user_id(); $ownsDiscussion = false; if ( $currentUserId && !empty( $discussion['userId'] ) && (int) $discussion['userId'] === (int) $currentUserId ) { $ownsDiscussion = true; } else if ( !$currentUserId && !empty( $params['session'] ) && !empty( $extra['session'] ) && hash_equals( (string) $extra['session'], (string) $params['session'] ) ) { $ownsDiscussion = true; } // Response IDs expire after 30 days per OpenAI's policy. $responseDate = !empty( $extra['previousResponseDate'] ) ? strtotime( $extra['previousResponseDate'] ) : 0; $thirtyDaysAgo = time() - ( 30 * 24 * 60 * 60 ); if ( $ownsDiscussion && $responseDate > $thirtyDaysAgo ) { $params['previousResponseId'] = $storedResponseId; } } } } else { // No discussion yet? We still need to check the startSentence. $startSentence = isset( $chatbot['startSentence'] ) ? $chatbot['startSentence'] : null; $messages = []; if ( !empty( $startSentence ) ) { $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ]; } $clientMessages = isset( $params['messages'] ) ? $params['messages'] : []; $diffs = $this->messages_integrity_diff( $messages, $clientMessages ); if ( count( $diffs ) > 0 ) { Meow_MWAI_Logging::warn( 'Integrity Check: It seems the messages in the discussion do not match the ones sent by the client: ' . json_encode( $diffs ) ); } } } // Create QueryText $context = null; $streamCallback = null; $mode = $chatbot['mode'] ?? 'chat'; if ( $mode === 'images' ) { // Check for uploaded files $fileForImage = null; if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) { $fileForImage = $newFileIds[0]; } elseif ( !empty( $newFileId ) ) { $fileForImage = $newFileId; } // If there's an uploaded file, use EditImage query instead if ( !empty( $fileForImage ) ) { $query = new Meow_MWAI_Query_EditImage( $newMessage ); // Handle the uploaded image $url = $this->core->files->get_url( $fileForImage ); $mimeType = $this->core->files->get_mime_type( $fileForImage ); $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] ); if ( $isIMG ) { $query->add_file( Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType ) ); $fileId = $this->core->files->get_id_from_refId( $fileForImage ); $this->core->files->update_purpose( $fileId, 'analysis' ); } } else { $query = new Meow_MWAI_Query_Image( $newMessage ); } // Handle Params $newParams = []; foreach ( $chatbot as $key => $value ) { $newParams[$key] = $value; } if ( is_array( $params ) ) { foreach ( $params as $key => $value ) { $newParams[$key] = $value; } } // Map 'environment' field to 'envId' for compatibility if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) { $newParams['envId'] = $newParams['environment']; } $params = apply_filters( 'mwai_chatbot_params', $newParams ); $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope']; // Debug log for embeddings if ( !empty( $params['embeddingsEnvId'] ) ) { Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] ); } else { // Log all params to debug $paramKeys = array_keys( $params ); Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) ); } $query->inject_params( $params ); } else { $query = $mode === 'assistant' ? new Meow_MWAI_Query_Assistant( $newMessage ) : new Meow_MWAI_Query_Text( $newMessage, 4096 ); // Handle Params $newParams = []; foreach ( $chatbot as $key => $value ) { $newParams[$key] = $value; } if ( is_array( $params ) ) { foreach ( $params as $key => $value ) { $newParams[$key] = $value; } } // Map 'environment' field to 'envId' for compatibility if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) { $newParams['envId'] = $newParams['environment']; } $params = apply_filters( 'mwai_chatbot_params', $newParams ); $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope']; // Debug log for embeddings if ( !empty( $params['embeddingsEnvId'] ) ) { Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] ); } else { // Log all params to debug $paramKeys = array_keys( $params ); Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) ); } // In Prompt mode, clear out features that are not supported before injecting params if ( $mode === 'prompt' ) { // Clear embeddings/context settings unset( $params['embeddingsEnvId'] ); unset( $params['embeddingsIndex'] ); unset( $params['embeddingsNamespace'] ); unset( $params['contentAware'] ); unset( $params['context'] ); // Clear function calling and MCP servers unset( $params['functions'] ); unset( $params['mcpServers'] ); // Clear tools unset( $params['tools'] ); // Clear temperature, reasoning, verbosity as they're configured in the prompt unset( $params['temperature'] ); unset( $params['reasoningEffort'] ); unset( $params['verbosity'] ); unset( $params['maxTokens'] ); } $query->inject_params( $params ); // Handle Prompt mode specifics if ( $mode === 'prompt' && !empty( $params['promptId'] ) ) { $promptData = [ 'id' => $params['promptId'] ]; $query->setExtraParam( 'prompt', $promptData ); } $storeId = null; if ( $mode === 'assistant' ) { $chatId = $params['chatId'] ?? null; if ( !empty( $chatId ) && $this->core->discussions ) { $discussion = $this->core->discussions->get_discussion( $query->botId, $chatId ); if ( isset( $discussion['storeId'] ) ) { $storeId = $discussion['storeId']; $query->setStoreId( $storeId ); } } } // Support for Multiple Uploaded Files $filesToProcess = []; if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) { $filesToProcess = $newFileIds; } elseif ( !empty( $newFileId ) ) { $filesToProcess[] = $newFileId; } // Files from earlier turns of this discussion. Only stateful providers // (OpenAI Responses, Google Interactions) keep them server-side through // previousResponseId; everyone else gets the history as plain text, so // the image or PDF from turn one silently vanished on turn two. // Re-attach them here. Files are tagged with query_chatId below and are // found again only while they exist (see the image_expires option). // Note: discussions store every provider's last id as previousResponseId // (Anthropic's msg_..., too), so "has an id" is not "has server-side // history"; only resp_ / v1_ ids get chained by their engines. $serverSideHistory = !empty( $query->previousResponseId ) && $query->historyStrategy !== 'internal' && $this->core->responseIdManager->is_stateful_conversation_id( $query->previousResponseId ); if ( $mode !== 'assistant' && !$serverSideHistory ) { $chatId = $this->files_chat_id( $query, $params ); $env = $this->core->get_ai_env( $query->envId ); foreach ( $this->core->files->get_chat_files( $chatId ) as $earlierFile ) { $refId = $earlierFile['refId']; if ( in_array( $refId, $filesToProcess, true ) ) { continue; } $mimeType = $this->core->files->get_mime_type( $refId ); $meta = $earlierFile['metadata'] ?? []; if ( in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] ) ) { $query->add_file( Meow_MWAI_Query_DroppedFile::from_url( $this->core->files->get_url( $refId ), 'analysis', $mimeType ) ); } else if ( !empty( $meta['file_id'] ) && !empty( $meta['provider'] ) && $meta['provider'] === ( $env['type'] ?? null ) ) { // Already on the provider's Files API (Anthropic): reference it, no re-upload. $query->add_file( Meow_MWAI_Query_DroppedFile::from_provider_file_id( $meta['file_id'], 'analysis', $mimeType ) ); } else { $query->add_file( Meow_MWAI_Query_DroppedFile::from_refId( $refId, 'analysis', $mimeType ) ); } } } // Support for Uploaded Image/Files if ( !empty( $filesToProcess ) ) { // Process all files for multi-upload support foreach ( $filesToProcess as $fileToProcess ) { // Get extension and mime type $isImage = $this->core->files->is_image( $fileToProcess ); if ( $mode === 'assistant' && !$isImage ) { // DEPRECATED: Assistants API and File Search are deprecated // After August 26, 2026, this entire block should be removed error_log( '[AI Engine] WARNING: Assistant File Search is deprecated and will be removed after August 26, 2026. Consider using regular chat with PDF uploads instead.' ); $url = $this->core->files->get_path( $fileToProcess ); $data = $this->core->files->get_data( $fileToProcess ); $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId ); $filename = basename( $url ); // Upload the file $file = $openai->upload_file( $filename, $data, 'assistants' ); // Create a store if ( empty( $storeId ) ) { $chatbotName = 'mwai_' . strtolower( !empty( $chatbot['name'] ) ? $chatbot['name'] : 'default' ); if ( !empty( $query->chatId ) ) { $chatbotName .= '_' . $query->chatId; } $metadata = []; if ( !empty( $chatbot['assistantId'] ) ) { $metadata['assistantId'] = $chatbot['assistantId']; } if ( !empty( $query->chatId ) ) { $metadata['chatId'] = $query->chatId; } $expiry = $this->core->get_option( 'image_expires' ); $storeId = $openai->create_vector_store( $chatbotName, $expiry, $metadata ); $query->setStoreId( $storeId ); } // Add the file to the store - wait a moment for store to be ready sleep( 1 ); $storeFileId = $openai->add_vector_store_file( $storeId, $file['id'] ); if ( empty( $storeFileId ) ) { throw new Exception( 'Failed to add file to vector store.' ); } // Update the local file with the OpenAI RefId, StoreId and StoreFileId $openAiRefId = $file['id']; $internalFileId = $this->core->files->get_id_from_refId( $fileToProcess ); $this->core->files->update_refId( $internalFileId, $openAiRefId ); $this->core->files->update_envId( $internalFileId, $query->envId ); $this->core->files->update_purpose( $internalFileId, 'analysis' ); $this->core->files->add_metadata( $internalFileId, 'assistant_storeId', $storeId ); $this->core->files->add_metadata( $internalFileId, 'assistant_storeFileId', $storeFileId ); $fileToProcess = $openAiRefId; $scope = $params['fileSearch']; if ( $scope === 'discussion' || $scope === 'user' || $scope === 'assistant' ) { $id = $this->core->files->get_id_from_refId( $fileToProcess ); $this->core->files->add_metadata( $id, 'assistant_scope', $scope ); } } else { // Keep track of the internal file ID (before any OpenAI processing) // Important: $fileToProcess is our internal database refId, not OpenAI's file_id $internalRefId = $fileToProcess; $url = $this->core->files->get_url( $internalRefId ); $mimeType = $this->core->files->get_mime_type( $internalRefId ); $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] ); // Create DroppedFile object - provider-agnostic approach // Images use URL (can be sent as base64 or URL in messages) // PDFs use refId (engines will upload to their Files API as needed) if ( $isIMG ) { $droppedFile = Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType ); } else { // For PDFs and documents, use refId so engines can access file data directly $droppedFile = Meow_MWAI_Query_DroppedFile::from_refId( $internalRefId, 'analysis', $mimeType ); } // IMPORTANT: Always use add_file() to add to attachedFiles array // This is the unified approach for both single and multi-file uploads // Engines will check attachedFiles array first, then fall back to attachedFile (legacy) $query->add_file( $droppedFile ); // Update metadata using the internal refId (not OpenAI file ID) $fileId = $this->core->files->get_id_from_refId( $internalRefId ); $this->core->files->update_envId( $fileId, $query->envId ); $this->core->files->update_purpose( $fileId, 'analysis' ); $this->core->files->add_metadata( $fileId, 'query_envId', $query->envId ); $this->core->files->add_metadata( $fileId, 'query_session', $query->session ); $this->core->files->add_metadata( $fileId, 'query_chatId', $this->files_chat_id( $query, $params ) ); } } } // Takeover $takeoverAnswer = apply_filters( 'mwai_chatbot_takeover', null, $query, $params ); if ( !empty( $takeoverAnswer ) ) { $reply = new Meow_MWAI_Reply( $query ); $reply->result = $takeoverAnswer; $rawText = apply_filters( 'mwai_chatbot_reply', $takeoverAnswer, $reply, $params, [] ); return [ 'reply' => $rawText, 'chatId' => $this->core->fix_chat_id( $query, $params ), 'images' => null, 'actions' => [], 'usage' => null ]; } // Moderation $moderationEnabled = $this->core->get_option( 'module_moderation' ) && $this->core->get_option( 'shortcode_chat_moderation' ); if ( $moderationEnabled ) { global $mwai; $isFlagged = $mwai->moderationCheck( $query->get_message() ); if ( $isFlagged ) { throw new Exception( 'Sorry, your message has been rejected by moderation.' ); } } // Setup streaming if enabled (before embeddings to capture those events) $streamCallback = null; $debugEvents = []; if ( $stream ) { $streamCallback = function ( $reply ) use ( $query ) { // Support both legacy string data and new Event objects if ( is_string( $reply ) ) { $this->core->stream_push( [ 'type' => 'live', 'data' => $reply ], $query ); } else { $this->core->stream_push( $reply, $query ); } }; if ( headers_sent( $filename, $linenum ) ) { throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." ); } header( 'Cache-Control: no-cache' ); header( 'Content-Type: text/event-stream' ); // This is useful to disable buffering in nginx through headers. header( 'X-Accel-Buffering: no' ); ob_implicit_flush( true ); if ( ob_get_level() > 0 ) { ob_end_flush(); } // Usage and credits are committed (via the mwai_ai_reply filter) only // after the model stream finishes. Without this, a client that // disconnects mid-stream kills the PHP script on the next flush, // before recording anything: the provider tokens are spent but the // query is never counted and the limit never decremented, so aborting // repeatedly evades usage limits. Finish the request even if the // client left so the accounting stays honest. ignore_user_abort( true ); } else if ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ) { // For non-streaming debug mode, collect events $streamCallback = function ( $event ) use ( &$debugEvents ) { if ( is_object( $event ) && method_exists( $event, 'toArray' ) ) { $debugEvents[] = $event->toArray(); } }; } // Awareness & Embeddings $context = $this->core->retrieve_context( $params, $query, $streamCallback ); if ( !empty( $context ) ) { $query->set_context( $context['content'] ); } // Function Aware $query = apply_filters( 'mwai_chatbot_query', $query, $params ); } // Process Query $reply = $this->core->run_query( $query, $streamCallback, true ); $rawText = $reply->result; $extra = []; if ( $context ) { $extra = [ 'embeddings' => isset( $context['embeddings'] ) ? $context['embeddings'] : null ]; } // Tools executed during this turn (functions, MCP, etc), so the Discussions // admin screen can show what ran and with which parameters. if ( !empty( $reply->toolCalls ) ) { $extra['toolCalls'] = $reply->toolCalls; } // Store response ID for Responses API stateful conversations // CRITICAL: Must store even when function calls are present // This enables the feedback query to use previous_response_id // // ...with one exception: client-side functions (target 'js'). Those are dispatched // to the browser and never produce a function_call_output, so the provider-side // conversation ends on a function_call that will never be answered. Stateful APIs // reject the NEXT turn outright with "No tool output found for function call // call_xxx" (invalid_request_error), which strands the whole discussion: the JS // tool ran fine, then every following message fails. So we deliberately break the // response-id chain here. The next turn falls back to sending the local history, // which costs a few more tokens and loses nothing the user can see. $hasUnresolvedClientActions = !empty( $reply->needClientActions ) && !empty( $reply->id ) && $this->core->responseIdManager->is_stateful_conversation_id( $reply->id ); if ( !empty( $reply->id ) && !$hasUnresolvedClientActions ) { $extra['responseId'] = $reply->id; $extra['responseDate'] = gmdate( 'Y-m-d H:i:s' ); // Track age for 30-day expiry } else if ( $hasUnresolvedClientActions ) { // Explicit null (not merely absent) so the stored chain is cleared rather than // left pointing at an earlier turn. See store_chat() in discussions.php. $extra['responseId'] = null; } // The client keeps its own copy of the id (useChatSession), so it needs telling too. $resetResponseId = $hasUnresolvedClientActions; $rawText .= Meow_MWAI_Core::get_truncated_notice( $reply, 'chatbot' ); $rawText = apply_filters( 'mwai_chatbot_reply', $rawText, $reply, $params, $extra ); // Integrity Check: We need to store the checksum of the messages sent by the client. $stored_messages = $client_messages; $stored_messages[] = [ 'role' => 'user', 'content' => $newMessage ]; $stored_messages[] = [ 'role' => 'assistant', 'content' => $rawText ]; $stored_checksum = $this->calculate_messages_checksum( $stored_messages ); set_transient( $checksum_key, $stored_checksum, 60 * 60 * 24 * 30 ); // Actions $actions = []; if ( $reply->needClientActions ) { foreach ( $reply->needClientActions as $action ) { $actions[] = [ 'type' => 'function', 'data' => [ 'name' => $action['function']->name, 'args' => $action['arguments'] ] ]; } } $restRes = [ 'reply' => $rawText, 'chatId' => $this->core->fix_chat_id( $query, $params ), 'images' => $reply->get_type() === 'images' ? $reply->results : null, 'actions' => $actions, 'usage' => $reply->usage ]; // Add debug events if collected if ( !empty( $debugEvents ) ) { $restRes['debugEvents'] = $debugEvents; } // Add response ID if available (for Responses API). Withheld when the turn ended // on an unanswered client-side function call, so clients that chain on it // (Workspace, useChatSession) do not send back an id the provider will reject. if ( !empty( $reply->id ) && !$hasUnresolvedClientActions ) { $restRes['responseId'] = $reply->id; } else if ( $resetResponseId ) { $restRes['resetResponseId'] = true; } // Process Reply if ( $stream ) { $final_res = $this->build_final_res( $botId, $newMessage, $newFileId, $params, $restRes['reply'], $restRes['images'], $restRes['actions'], $restRes['usage'], $restRes['responseId'] ?? null, !empty( $restRes['resetResponseId'] ) ); $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query ); die(); } else { return $restRes; } } catch ( Meow_MWAI_ApprovalRequiredException $e ) { // Not an error: the AI wants to run a tool that needs the user's // approval (Workspace WordPress Tools). Pause the turn, hand the pending // call to the UI, and store nothing; the turn is re-run once the user // decides (with their decision as a request param). $approval = [ 'tool' => $e->tool, 'args' => $e->args ]; if ( $stream ) { $this->core->stream_push( [ 'type' => 'live', 'subtype' => 'approval_request', 'data' => 'Waiting for your approval to run ' . $e->tool . '...', 'metadata' => $approval, ], $query ); $final_res = $this->build_final_res( $botId, $newMessage, $newFileId, $params, '', null, [], [] ); $final_res['approval'] = $approval; $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query ); die(); } return [ 'reply' => '', 'images' => null, 'actions' => [], 'usage' => [], 'approval' => $approval ]; } catch ( Exception $e ) { $message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); if ( $stream ) { // In streaming mode this catch runs before rest_chat's, so the refusal // handling has to happen here too: overLimit lets the frontend lock the // input, and internal errors stay generic for visitors (admins get the // real one, they can act on it). $isRefusal = $e instanceof Meow_MWAI_RefusedException; $overLimit = $isRefusal && $e->reason === 'limits'; if ( !$isRefusal && !current_user_can( 'manage_options' ) ) { if ( empty( $this->core->streamVisitorLeft ) ) { error_log( '[AI Engine Chatbot Error] ' . Meow_MWAI_Core::log_safe( $e->getMessage() ) . ' in ' . $e->getFile() . ':' . $e->getLine() ); } $message = Meow_MWAI_Core::get_public_error_message( $e ); } $this->core->stream_push( [ 'type' => 'error', 'data' => $message, 'overLimit' => $overLimit ], $query ); die(); } else { throw $e; } } } public function inject_chat() { // The page already placed this same chatbot as a popup: injecting it again stacked two identical // launchers (same element id) on the same corner. Different chatbots are left to the warning below. if ( in_array( "id={$this->siteWideChatId}", $this->renderedPopups, true ) ) { return null; } $params = $this->core->get_chatbot( $this->siteWideChatId ); $clean_params = []; if ( !empty( $params ) ) { $clean_params['window'] = true; $clean_params['id'] = $this->siteWideChatId; echo $this->chat_shortcode( $clean_params ); } return null; } // Two popups on one page put their launchers on the same pixel, which reads as a chatbot that is // open and minimised at the same time. We do not pick one for you: the page might want that, and // silently dropping a chatbot someone placed on purpose would be worse. So we say it, to the // people who can fix it. Runs at wp_footer, after the_content, so the list is complete. public function warn_about_stacked_popups() { if ( count( $this->renderedPopups ) < 2 ) { return; } $list = implode( ', ', array_map( 'sanitize_text_field', $this->renderedPopups ) ); $message = 'AI Engine: this page renders ' . count( $this->renderedPopups ) . " popup chatbots ({$list}). " . 'Their launchers overlap in the same corner, so opening one reveals the other underneath. ' . 'Set the shortcode to window="false", or turn off the site-wide chatbot for this page.'; // Unlike a config error, this fires on every single view of the offending page, so logging it // unconditionally would fill a busy site's error log with the same line. Editors get it on // screen, which is the actionable path; the log is for whoever is actually debugging. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[AI Engine] ' . $message ); } if ( current_user_can( 'edit_posts' ) ) { echo '