PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.3
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.3
3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / chatbot.php
ai-engine / classes / modules Last commit date
advisor.php 3 months ago chatbot.php 1 month ago discussions.php 2 months ago editor-assistant.php 3 months ago files.php 3 months ago forms-manager.php 3 months ago gdpr.php 4 months ago search.php 3 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
chatbot.php
1450 lines
1 <?php
2
3 // Params for the chatbot (front and server)
4 define( 'MWAI_CHATBOT_FRONT_PARAMS', [ 'id', 'customId', 'aiName', 'userName', 'guestName', 'aiAvatar', 'userAvatar', 'guestAvatar', 'aiAvatarUrl', 'userAvatarUrl', 'guestAvatarUrl', 'textSend', 'textClear', 'imageUpload', 'fileUpload', 'multiUpload', 'maxUploads', 'fileUploads', 'fileSearch', 'allowedMimeTypes', 'mode', 'textInputPlaceholder', 'textInputMaxLength', 'textCompliance', 'startSentence', 'localMemory', 'themeId', 'window', 'icon', 'iconText', 'iconTextDelay', 'iconAlt', 'iconPosition', 'iconSize', 'centerOpen', 'width', 'maxHeight', 'openDelay', 'iconBubble', 'windowAnimation', 'fullscreen', 'copyButton', 'pdfButton', 'headerSubtitle', 'popupTitle', 'containerType', 'headerType', 'messagesType', 'inputType', 'footerType', 'talkMode' ] );
5
6 define( 'MWAI_CHATBOT_SERVER_PARAMS', [ 'id', 'envId', 'scope', 'mode', 'contentAware', 'context', 'startSentence', 'embeddingsEnvId', 'embeddingsIndex', 'embeddingsNamespace', 'assistantId', 'instructions', 'resolution', 'voice', 'talkMode', 'model', 'temperature', 'maxTokens', 'contextMaxLength', 'maxResults', 'apiKey', 'functions', 'mcpServers', 'tools', 'historyStrategy', 'previousResponseId', 'parentBotId', 'crossSite', 'promptId', 'promptVariables', 'reasoningEffort', 'verbosity' ] );
7
8 // Params for the discussions (front and server)
9 define( 'MWAI_DISCUSSIONS_FRONT_PARAMS', [ 'themeId', 'textNewChat' ] );
10 define( 'MWAI_DISCUSSIONS_SERVER_PARAMS', [ 'customId' ] );
11
12 class Meow_MWAI_Modules_Chatbot {
13 private $core = null;
14 private $namespace = 'mwai-ui/v1';
15 private $siteWideChatId = null;
16
17 public function __construct() {
18 global $mwai_core;
19 $this->core = $mwai_core;
20 $this->siteWideChatId = $this->core->get_option( 'botId' );
21
22 add_shortcode( 'mwai_chatbot', [ $this, 'chat_shortcode' ] );
23 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
24 add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] );
25 add_action( 'admin_enqueue_scripts', [ $this, 'register_scripts' ] );
26 if ( $this->core->get_option( 'chatbot_discussions' ) ) {
27 add_shortcode( 'mwai_discussions', [ $this, 'chatbot_discussions' ] );
28 }
29 }
30
31 public function register_scripts() {
32 // Load JS
33 $physical_file = trailingslashit( MWAI_PATH ) . 'app/chatbot.js';
34 $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
35 wp_register_script( 'mwai_chatbot', trailingslashit( MWAI_URL )
36 . 'app/chatbot.js', [ 'wp-element' ], $cache_buster, false );
37
38 // Actual loading of the scripts
39 $hasSiteWideChat = $this->siteWideChatId && $this->siteWideChatId !== 'none';
40
41 // Don't load chatbot scripts on the Site Editor to avoid conflicts
42 $current_screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
43 $is_site_editor = $current_screen && $current_screen->base === 'site-editor';
44
45 if ( ( is_admin() && !$is_site_editor ) || $hasSiteWideChat ) {
46 $themeId = null;
47 if ( $hasSiteWideChat ) {
48 $bot = $this->core->get_chatbot( $this->siteWideChatId );
49 if ( $bot && isset( $bot['themeId'] ) ) {
50 $themeId = $bot['themeId'];
51 }
52 }
53 $this->enqueue_scripts( is_admin() ? null : $themeId );
54 if ( $hasSiteWideChat ) {
55 // Chatbot Injection
56 add_action( 'wp_footer', [ $this, 'inject_chat' ] );
57 }
58 }
59 }
60
61 public function enqueue_scripts( $themeId = null ) {
62 wp_enqueue_script( 'mwai_chatbot' );
63 if ( $this->core->get_option( 'syntax_highlight' ) ) {
64 wp_enqueue_script( 'mwai_highlight' );
65 }
66 if ( $themeId ) {
67 $this->core->enqueue_theme( $themeId );
68 }
69 else {
70 $this->core->enqueue_themes();
71 }
72 }
73
74 /**
75 * Helper method to create REST responses with automatic token refresh
76 *
77 * @param array $data The response data
78 * @param int $status HTTP status code
79 * @return WP_REST_Response
80 */
81 protected function create_rest_response( $data, $status = 200 ) {
82 // Always check if we need to provide a new nonce
83 $current_nonce = $this->core->get_nonce( true );
84 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
85
86 // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours)
87 // We'll refresh if the nonce is older than 10 hours to be safe
88 $should_refresh = false;
89
90 if ( $request_nonce ) {
91 // Try to determine the age of the nonce
92 // WordPress uses a tick system where each tick is 12 hours
93 // If we're in the second half of the nonce's life, refresh it
94 $time = time();
95 $nonce_tick = wp_nonce_tick();
96
97 // Verify if the nonce is still valid but getting old
98 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
99 if ( $verify === 2 ) {
100 // Nonce is valid but was generated 12-24 hours ago
101 $should_refresh = true;
102 // Log will be written when token is included in response
103 }
104 }
105
106 // If the nonce has changed or should be refreshed, include the new one
107 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
108 $data['new_token'] = $current_nonce;
109
110 // Log if server debug mode is enabled
111 if ( $this->core->get_option( 'server_debug_mode' ) ) {
112 error_log( '[AI Engine] Token refresh: Nonce refreshed (12-24 hours old)' );
113 }
114 }
115
116 return new WP_REST_Response( $data, $status );
117 }
118
119 public function rest_api_init() {
120 register_rest_route( $this->namespace, '/chats/submit', [
121 'methods' => 'POST',
122 'callback' => [ $this, 'rest_chat' ],
123 'permission_callback' => [ $this->core, 'check_rest_nonce' ]
124 ] );
125 }
126
127 public function basics_security_check( $botId, $customId, $newMessage, $newFileId ) {
128 if ( !$botId && !$customId ) {
129 Meow_MWAI_Logging::warn( 'The query was rejected - no botId nor id was specified.' );
130 return false;
131 }
132
133 if ( $newFileId ) {
134 return true;
135 }
136
137 // Handle null or convert to string for strlen
138 $messageStr = $newMessage === null ? '' : (string) $newMessage;
139 $length = strlen( $messageStr );
140 if ( $length < 1 ) {
141 Meow_MWAI_Logging::warn( 'The query was rejected - message was too short.' );
142 return false;
143 }
144 return true;
145 }
146
147 public function build_final_res( $botId, $newMessage, $newFileId, $params, $reply, $images, $actions, $usage, $responseId = null ) {
148 $filterParams = [
149 'step' => 'reply',
150 'botId' => $botId,
151 'reply' => $reply,
152 'images' => $images,
153 'newMessage' => $newMessage,
154 'newFileId' => $newFileId,
155 'params' => $params,
156 'usage' => $usage,
157 'messages' => $params['messages'] ?? [],
158 'isNewConversation' => empty( $params['messages'] ) || count( $params['messages'] ) <= 1,
159 ];
160 $actions = apply_filters( 'mwai_chatbot_actions', $actions, $filterParams );
161 $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams );
162 $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams );
163 $actions = $this->sanitize_actions( $actions );
164 $blocks = $this->sanitize_blocks( $blocks );
165 $shortcuts = $this->sanitize_shortcuts( $shortcuts );
166 $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
167 $result = [
168 'success' => true,
169 'reply' => $reply,
170 'images' => $images,
171 'actions' => $actions,
172 'shortcuts' => $shortcuts,
173 'blocks' => $blocks,
174 'usage' => $usage
175 ];
176
177 // Add response ID if available
178 if ( !empty( $responseId ) ) {
179 $result['responseId'] = $responseId;
180 }
181
182 // Check if token needs refresh
183 $current_nonce = $this->core->get_nonce( true );
184 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
185
186 $should_refresh = false;
187 if ( $request_nonce ) {
188 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
189 if ( $verify === 2 ) {
190 // Nonce is valid but was generated 12-24 hours ago
191 $should_refresh = true;
192 }
193 }
194
195 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
196 $result['new_token'] = $current_nonce;
197 }
198
199 return $result;
200 }
201
202 public function rest_chat( $request ) {
203 $params = $request->get_json_params();
204 $botId = $params['botId'] ?? null;
205 $customId = $params['customId'] ?? null;
206 $stream = $params['stream'] ?? false;
207 $newMessage = trim( $params['newMessage'] ?? '' );
208 $newFileId = $params['newFileId'] ?? null;
209 $newFileIds = $params['newFileIds'] ?? [];
210 $crossSite = $params['crossSite'] ?? false;
211 $shortcutId = $params['shortcutId'] ?? null;
212
213 // If shortcutId is provided, look up the actual message
214 if ( $shortcutId && empty( $newMessage ) ) {
215 $shortcutMessage = $this->get_shortcut_message( $shortcutId, $botId );
216 if ( $shortcutMessage ) {
217 $newMessage = $shortcutMessage;
218 }
219 else {
220 return $this->create_rest_response( [
221 'success' => false,
222 'message' => 'Invalid or expired shortcut.'
223 ], 400 );
224 }
225 }
226
227 if ( !$this->basics_security_check( $botId, $customId, $newMessage, $newFileId ) ) {
228 return $this->create_rest_response( [
229 'success' => false,
230 'message' => apply_filters( 'mwai_ai_exception', 'Sorry, your query has been rejected.' )
231 ], 403 );
232 }
233
234 try {
235 $data = $this->chat_submit( $botId, $newMessage, $newFileId, $params, $stream, $newFileIds );
236 $final_res = $this->build_final_res(
237 $botId,
238 $newMessage,
239 $newFileId,
240 $params,
241 $data['reply'],
242 $data['images'],
243 $data['actions'],
244 $data['usage'],
245 $data['responseId'] ?? null
246 );
247 return $this->create_rest_response( $final_res, 200 );
248 }
249 catch ( Exception $e ) {
250 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
251
252 // If we're in streaming mode, send the error through the stream
253 if ( $stream ) {
254 // Log the error
255 error_log( '[AI Engine Chatbot Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
256
257 // Send error event through stream
258 $errorData = [
259 'type' => 'error',
260 'data' => 'Oops! Something went wrong on the server. Please try again, and if you are the site developer, check the PHP Error Logs for details.'
261 ];
262 echo 'data: ' . json_encode( $errorData ) . "\n\n";
263 if ( ob_get_level() > 0 ) {
264 ob_end_flush();
265 }
266 flush();
267 die();
268 }
269
270 // For non-streaming, return normal error response
271 return $this->create_rest_response( [
272 'success' => false,
273 'message' => $message
274 ], 500 );
275 }
276 }
277
278 private function sanitize_items( $items, $supported_types, $type_name ) {
279 if ( empty( $items ) ) {
280 return $items;
281 }
282 $sanitized_items = [];
283 foreach ( $items as $item ) {
284 if ( isset( $supported_types[$item['type']] ) ) {
285 $is_valid = true;
286 foreach ( $supported_types[$item['type']] as $param ) {
287 if ( !isset( $item['data'][$param] ) ) {
288 $is_valid = false;
289 Meow_MWAI_Logging::warn( "The query was rejected - missing required parameter '{$param}' for {$type_name} type: {$item['type']}." );
290 break;
291 }
292 }
293 if ( $is_valid ) {
294 $sanitized_items[] = $item;
295 }
296 }
297 else {
298 Meow_MWAI_Logging::warn( "The query was rejected - unsupported {$type_name} type: {$item['type']}." );
299 }
300 }
301 return $sanitized_items;
302 }
303
304 public function sanitize_actions( $actions ) {
305 $supported_action_types = [
306 'function' => ['name', 'args'],
307 'javascript' => ['snippet'],
308 ];
309 return $this->sanitize_items( $actions, $supported_action_types, 'action' );
310 }
311
312 public function sanitize_blocks( $blocks ) {
313 $supported_block_types = [
314 'content' => ['html'],
315 ];
316 return $this->sanitize_items( $blocks, $supported_block_types, 'block' );
317 }
318
319 public function sanitize_shortcuts( $shortcuts ) {
320 $supported_shortcut_types = [
321 'message' => ['label', 'message'],
322 'action' => ['label', 'message', 'action'],
323 'callback' => ['label', 'onClick'],
324 ];
325 return $this->sanitize_items( $shortcuts, $supported_shortcut_types, 'shortcut' );
326 }
327
328 /**
329 * Get the encryption key derived from WordPress salts.
330 *
331 * @return string The 32-byte encryption key.
332 */
333 private function get_shortcut_encryption_key() {
334 return substr( hash( 'sha256', wp_salt( 'auth' ) . 'mwai_shortcuts' ), 0, 32 );
335 }
336
337 /**
338 * Encrypt shortcut data for safe transmission to the client.
339 *
340 * @param array $data The data to encrypt (message, botId).
341 * @return string|null The base64-encoded encrypted payload, or null on failure.
342 */
343 private function encrypt_shortcut_data( $data ) {
344 $key = $this->get_shortcut_encryption_key();
345 $iv = openssl_random_pseudo_bytes( 16 );
346 $json = json_encode( $data );
347 $encrypted = openssl_encrypt( $json, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
348 if ( $encrypted === false ) {
349 return null;
350 }
351 return base64_encode( $iv . $encrypted );
352 }
353
354 /**
355 * Decrypt shortcut data received from the client.
356 *
357 * @param string $payload The base64-encoded encrypted payload.
358 * @return array|null The decrypted data, or null on failure.
359 */
360 private function decrypt_shortcut_data( $payload ) {
361 $key = $this->get_shortcut_encryption_key();
362 $decoded = base64_decode( $payload, true );
363 if ( $decoded === false || strlen( $decoded ) < 17 ) {
364 return null;
365 }
366 $iv = substr( $decoded, 0, 16 );
367 $encrypted = substr( $decoded, 16 );
368 $decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
369 if ( $decrypted === false ) {
370 return null;
371 }
372 return json_decode( $decrypted, true );
373 }
374
375 /**
376 * Prepare shortcuts for client by replacing messages with encrypted shortcutIds.
377 * The messages are encrypted and can only be decrypted server-side.
378 * This keeps the prompt content private and not exposed in the browser.
379 *
380 * @param array $shortcuts The shortcuts to prepare.
381 * @param string $botId The bot ID for validation.
382 * @return array The prepared shortcuts with encrypted shortcutIds instead of messages.
383 */
384 public function prepare_shortcuts_for_client( $shortcuts, $botId ) {
385 if ( empty( $shortcuts ) ) {
386 return $shortcuts;
387 }
388
389 $prepared = [];
390 foreach ( $shortcuts as $shortcut ) {
391 $type = $shortcut['type'] ?? '';
392 $data = $shortcut['data'] ?? [];
393
394 // Only process shortcuts that have a message (not callbacks)
395 if ( isset( $data['message'] ) && !empty( $data['message'] ) ) {
396 // Encrypt the message and botId
397 $shortcutId = $this->encrypt_shortcut_data( [
398 'message' => $data['message'],
399 'botId' => $botId,
400 ] );
401
402 if ( $shortcutId ) {
403 // Replace message with encrypted shortcutId
404 unset( $data['message'] );
405 $data['shortcutId'] = $shortcutId;
406 }
407 }
408
409 $prepared[] = [
410 'type' => $type,
411 'data' => $data,
412 ];
413 }
414
415 return $prepared;
416 }
417
418 /**
419 * Decrypt and retrieve a shortcut message from its encrypted ID.
420 *
421 * @param string $shortcutId The encrypted shortcut ID.
422 * @param string $botId The bot ID for validation.
423 * @return string|null The message, or null if decryption fails or botId mismatches.
424 */
425 public function get_shortcut_message( $shortcutId, $botId ) {
426 if ( empty( $shortcutId ) ) {
427 return null;
428 }
429
430 $shortcut_data = $this->decrypt_shortcut_data( $shortcutId );
431
432 if ( !$shortcut_data || !isset( $shortcut_data['message'] ) ) {
433 Meow_MWAI_Logging::warn( "Shortcut decryption failed for botId: {$botId}" );
434 return null;
435 }
436
437 // Validate botId matches (security check)
438 if ( isset( $shortcut_data['botId'] ) && $shortcut_data['botId'] !== $botId ) {
439 Meow_MWAI_Logging::warn( "Shortcut botId mismatch: expected {$shortcut_data['botId']}, got {$botId}" );
440 return null;
441 }
442
443 return $shortcut_data['message'];
444 }
445
446 #region Messages Integrity Check
447
448 public function messages_integrity_diff( $messages1, $messages2 ) {
449 // Ensure both parameters are arrays
450 if ( !is_array( $messages1 ) ) {
451 $messages1 = [];
452 }
453 if ( !is_array( $messages2 ) ) {
454 $messages2 = [];
455 }
456
457 // Collect messages with role not 'user' from messages1
458 $messagesList1 = [];
459 foreach ( $messages1 as $msg ) {
460 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
461 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
462 if ( $role && $role != 'user' ) {
463 $messageData = [ 'role' => $role, 'content' => $content ];
464 $messagesList1[] = $messageData;
465 }
466 }
467
468 // Collect messages with role not 'user' from messages2
469 $messagesList2 = [];
470 foreach ( $messages2 as $msg ) {
471 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
472 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
473 if ( $role && $role != 'user' ) {
474 $messageData = [ 'role' => $role, 'content' => $content ];
475 $messagesList2[] = $messageData;
476 }
477 }
478
479 // Count occurrences of each message in messagesList1
480 $counts1 = [];
481 foreach ( $messagesList1 as $msg ) {
482 $key = serialize( $msg );
483 if ( isset( $counts1[ $key ] ) ) {
484 $counts1[ $key ]++;
485 }
486 else {
487 $counts1[ $key ] = 1;
488 }
489 }
490
491 // Count occurrences of each message in messagesList2
492 $counts2 = [];
493 foreach ( $messagesList2 as $msg ) {
494 $key = serialize( $msg );
495 if ( isset( $counts2[ $key ] ) ) {
496 $counts2[ $key ]++;
497 }
498 else {
499 $counts2[ $key ] = 1;
500 }
501 }
502
503 // Compare counts to find unmatched messages
504 $all_keys = array_unique( array_merge( array_keys( $counts1 ), array_keys( $counts2 ) ) );
505
506 $diffs = [];
507 foreach ( $all_keys as $key ) {
508 $count1 = isset( $counts1[ $key ] ) ? $counts1[ $key ] : 0;
509 $count2 = isset( $counts2[ $key ] ) ? $counts2[ $key ] : 0;
510 if ( $count1 != $count2 ) {
511 $message = unserialize( $key );
512 $diffs[] = [
513 'message' => $message,
514 'count_in_messages1' => $count1,
515 'count_in_messages2' => $count2
516 ];
517 }
518 }
519
520 return $diffs;
521 }
522
523 private function calculate_messages_checksum( $messages ) {
524 $messages_to_hash = [];
525 foreach ( $messages as $msg ) {
526 $role = is_array( $msg ) ? ( $msg['role'] ?? '' ) : ( is_object( $msg ) ? ( $msg->role ?? '' ) : '' );
527 $content = is_array( $msg ) ? ( $msg['content'] ?? '' ) : ( is_object( $msg ) ? ( $msg->content ?? '' ) : '' );
528 if ( in_array( $role, ['assistant', 'system'] ) ) {
529 $messages_to_hash[] = [ 'role' => $role, 'content' => $content ];
530 }
531 }
532 return md5( json_encode( $messages_to_hash ) );
533 }
534
535 #endregion
536
537 public function chat_submit( $botId, $newMessage, $newFileId = null, $params = [], $stream = false, $newFileIds = [] ) {
538 $query = null; // Initialize query variable to avoid undefined variable errors
539 try {
540 $chatbot = null;
541 $customId = $params['customId'] ?? null;
542
543 // Custom Chatbot
544 if ( $customId ) {
545 $chatbot = get_transient( 'mwai_custom_chatbot_' . $customId );
546 }
547 // Registered Chatbot
548 if ( !$chatbot && $botId ) {
549 $chatbot = $this->core->get_chatbot( $botId );
550 }
551 // Internal Chatbots (reserved mwai_ prefix)
552 if ( !$chatbot && $botId && strpos( $botId, 'mwai_' ) === 0 ) {
553 $chatbot = apply_filters( 'mwai_internal_chatbot', null, $botId, $params );
554 }
555 // Fall back to default chatbot if no chatbot found yet
556 if ( !$chatbot ) {
557 $chatbot = $this->core->get_chatbot( 'default' );
558 }
559
560 if ( !$chatbot ) {
561 Meow_MWAI_Logging::warn( 'The query was rejected - no chatbot was found.' );
562 throw new Exception( 'Sorry, your query has been rejected.' );
563 }
564
565 $textInputMaxLength = $chatbot['textInputMaxLength'] ?? null;
566 if ( $textInputMaxLength && $this->core->safe_strlen( $newMessage ) > (int) $textInputMaxLength ) {
567 Meow_MWAI_Logging::warn( 'The query was rejected - message was too long.' );
568 throw new Exception( 'Sorry, your query has been rejected.' );
569 }
570
571 // We need to check the integrity of the messages sent by the client.
572 // This is important to ensure that the messages are not tampered with.
573
574 // Messages Integrity Check with Checksums
575 $chatId = $params['chatId'] ?? 'default';
576 $checksum_key = 'mwai_chatbot_checksum_' . $chatId;
577 $stored_checksum = get_transient( $checksum_key );
578 $client_messages = $params['messages'] ?? [];
579 $client_checksum = $this->calculate_messages_checksum( $client_messages );
580 if ( $stored_checksum && $stored_checksum !== $client_checksum ) {
581 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.' );
582 }
583
584 // Messages Integrity Check with Discussions
585 if ( $this->core->get_option( 'chatbot_discussions' ) && $this->core->discussions && isset( $params['chatId'] ) ) {
586 $discussion = $this->core->discussions->get_discussion( $botId ? $botId : $customId, $params['chatId'] );
587 if ( $discussion ) {
588 $messages = $discussion['messages'];
589 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
590 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
591 if ( count( $diffs ) > 0 ) {
592 Meow_MWAI_Logging::warn( "Integrity Check: It seems the messages in the discussion #{$discussion['id']} do not match the ones sent by the client." );
593 }
594
595 // Maintain conversation state for Responses API by loading previousResponseId
596 // This enables stateful conversations where only new messages are sent
597 if ( empty( $params['previousResponseId'] ) && !empty( $discussion['extra'] ) ) {
598 $extra = json_decode( $discussion['extra'], true );
599 if ( !empty( $extra['responseId'] ) ) {
600 // Response IDs expire after 30 days per OpenAI's policy
601 // Check if the stored response is still valid
602 $responseDate = !empty( $extra['responseDate'] ) ? strtotime( $extra['responseDate'] ) : 0;
603 $thirtyDaysAgo = time() - ( 30 * 24 * 60 * 60 );
604
605 if ( $responseDate > $thirtyDaysAgo ) {
606 // Use the stored response ID for stateful conversation
607 $params['previousResponseId'] = $extra['responseId'];
608 }
609 }
610 }
611 }
612 else {
613 // No discussion yet? We still need to check the startSentence.
614 $startSentence = isset( $chatbot['startSentence'] ) ? $chatbot['startSentence'] : null;
615 $messages = [];
616 if ( !empty( $startSentence ) ) {
617 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
618 }
619 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
620 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
621 if ( count( $diffs ) > 0 ) {
622 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 ) );
623 }
624 }
625 }
626
627 // Create QueryText
628 $context = null;
629 $streamCallback = null;
630 $mode = $chatbot['mode'] ?? 'chat';
631
632 if ( $mode === 'images' ) {
633 // Check for uploaded files
634 $fileForImage = null;
635 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
636 $fileForImage = $newFileIds[0];
637 }
638 elseif ( !empty( $newFileId ) ) {
639 $fileForImage = $newFileId;
640 }
641
642 // If there's an uploaded file, use EditImage query instead
643 if ( !empty( $fileForImage ) ) {
644 $query = new Meow_MWAI_Query_EditImage( $newMessage );
645
646 // Handle the uploaded image
647 $url = $this->core->files->get_url( $fileForImage );
648 $mimeType = $this->core->files->get_mime_type( $fileForImage );
649 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
650
651 if ( $isIMG ) {
652 $query->add_file( Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType ) );
653 $fileId = $this->core->files->get_id_from_refId( $fileForImage );
654 $this->core->files->update_purpose( $fileId, 'analysis' );
655 }
656 }
657 else {
658 $query = new Meow_MWAI_Query_Image( $newMessage );
659 }
660
661 // Handle Params
662 $newParams = [];
663 foreach ( $chatbot as $key => $value ) {
664 $newParams[$key] = $value;
665 }
666 if ( is_array( $params ) ) {
667 foreach ( $params as $key => $value ) {
668 $newParams[$key] = $value;
669 }
670 }
671
672 // Map 'environment' field to 'envId' for compatibility
673 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
674 $newParams['envId'] = $newParams['environment'];
675 }
676
677 $params = apply_filters( 'mwai_chatbot_params', $newParams );
678 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
679
680 // Debug log for embeddings
681 if ( !empty( $params['embeddingsEnvId'] ) ) {
682 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
683 }
684 else {
685 // Log all params to debug
686 $paramKeys = array_keys( $params );
687 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
688 }
689
690 $query->inject_params( $params );
691 }
692 else {
693 $query = $mode === 'assistant' ? new Meow_MWAI_Query_Assistant( $newMessage ) :
694 new Meow_MWAI_Query_Text( $newMessage, 4096 );
695
696 // Handle Params
697 $newParams = [];
698 foreach ( $chatbot as $key => $value ) {
699 $newParams[$key] = $value;
700 }
701 if ( is_array( $params ) ) {
702 foreach ( $params as $key => $value ) {
703 $newParams[$key] = $value;
704 }
705 }
706
707 // Map 'environment' field to 'envId' for compatibility
708 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
709 $newParams['envId'] = $newParams['environment'];
710 }
711
712 $params = apply_filters( 'mwai_chatbot_params', $newParams );
713 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
714
715 // Debug log for embeddings
716 if ( !empty( $params['embeddingsEnvId'] ) ) {
717 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
718 }
719 else {
720 // Log all params to debug
721 $paramKeys = array_keys( $params );
722 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
723 }
724
725 // In Prompt mode, clear out features that are not supported before injecting params
726 if ( $mode === 'prompt' ) {
727 // Clear embeddings/context settings
728 unset( $params['embeddingsEnvId'] );
729 unset( $params['embeddingsIndex'] );
730 unset( $params['embeddingsNamespace'] );
731 unset( $params['contentAware'] );
732 unset( $params['context'] );
733
734 // Clear function calling and MCP servers
735 unset( $params['functions'] );
736 unset( $params['mcpServers'] );
737
738 // Clear tools
739 unset( $params['tools'] );
740
741 // Clear temperature, reasoning, verbosity as they're configured in the prompt
742 unset( $params['temperature'] );
743 unset( $params['reasoningEffort'] );
744 unset( $params['verbosity'] );
745 unset( $params['maxTokens'] );
746 }
747
748 $query->inject_params( $params );
749
750 // Handle Prompt mode specifics
751 if ( $mode === 'prompt' && !empty( $params['promptId'] ) ) {
752 $promptData = [ 'id' => $params['promptId'] ];
753 $query->setExtraParam( 'prompt', $promptData );
754 }
755
756 $storeId = null;
757 if ( $mode === 'assistant' ) {
758 $chatId = $params['chatId'] ?? null;
759 if ( !empty( $chatId ) && $this->core->discussions ) {
760 $discussion = $this->core->discussions->get_discussion( $query->botId, $chatId );
761 if ( isset( $discussion['storeId'] ) ) {
762 $storeId = $discussion['storeId'];
763 $query->setStoreId( $storeId );
764 }
765 }
766 }
767
768 // Support for Multiple Uploaded Files
769 $filesToProcess = [];
770 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
771 $filesToProcess = $newFileIds;
772 }
773 elseif ( !empty( $newFileId ) ) {
774 $filesToProcess[] = $newFileId;
775 }
776
777 // Support for Uploaded Image/Files
778 if ( !empty( $filesToProcess ) ) {
779 // Process all files for multi-upload support
780 foreach ( $filesToProcess as $fileToProcess ) {
781 // Get extension and mime type
782 $isImage = $this->core->files->is_image( $fileToProcess );
783
784 if ( $mode === 'assistant' && !$isImage ) {
785 // DEPRECATED: Assistants API and File Search are deprecated
786 // After August 26, 2026, this entire block should be removed
787 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.' );
788
789 $url = $this->core->files->get_path( $fileToProcess );
790 $data = $this->core->files->get_data( $fileToProcess );
791 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId );
792 $filename = basename( $url );
793
794 // Upload the file
795 $file = $openai->upload_file( $filename, $data, 'assistants' );
796
797 // Create a store
798 if ( empty( $storeId ) ) {
799 $chatbotName = 'mwai_' . strtolower( !empty( $chatbot['name'] ) ? $chatbot['name'] : 'default' );
800 if ( !empty( $query->chatId ) ) {
801 $chatbotName .= '_' . $query->chatId;
802 }
803 $metadata = [];
804 if ( !empty( $chatbot['assistantId'] ) ) {
805 $metadata['assistantId'] = $chatbot['assistantId'];
806 }
807 if ( !empty( $query->chatId ) ) {
808 $metadata['chatId'] = $query->chatId;
809 }
810 $expiry = $this->core->get_option( 'image_expires' );
811 $storeId = $openai->create_vector_store( $chatbotName, $expiry, $metadata );
812 $query->setStoreId( $storeId );
813 }
814
815 // Add the file to the store - wait a moment for store to be ready
816 sleep( 1 );
817 $storeFileId = $openai->add_vector_store_file( $storeId, $file['id'] );
818
819 if ( empty( $storeFileId ) ) {
820 throw new Exception( 'Failed to add file to vector store.' );
821 }
822
823 // Update the local file with the OpenAI RefId, StoreId and StoreFileId
824 $openAiRefId = $file['id'];
825 $internalFileId = $this->core->files->get_id_from_refId( $fileToProcess );
826 $this->core->files->update_refId( $internalFileId, $openAiRefId );
827 $this->core->files->update_envId( $internalFileId, $query->envId );
828 $this->core->files->update_purpose( $internalFileId, 'analysis' );
829 $this->core->files->add_metadata( $internalFileId, 'assistant_storeId', $storeId );
830 $this->core->files->add_metadata( $internalFileId, 'assistant_storeFileId', $storeFileId );
831 $fileToProcess = $openAiRefId;
832 $scope = $params['fileSearch'];
833 if ( $scope === 'discussion' || $scope === 'user' || $scope === 'assistant' ) {
834 $id = $this->core->files->get_id_from_refId( $fileToProcess );
835 $this->core->files->add_metadata( $id, 'assistant_scope', $scope );
836 }
837 }
838 else {
839 // Keep track of the internal file ID (before any OpenAI processing)
840 // Important: $fileToProcess is our internal database refId, not OpenAI's file_id
841 $internalRefId = $fileToProcess;
842 $url = $this->core->files->get_url( $internalRefId );
843 $mimeType = $this->core->files->get_mime_type( $internalRefId );
844 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
845
846 // Create DroppedFile object - provider-agnostic approach
847 // Images use URL (can be sent as base64 or URL in messages)
848 // PDFs use refId (engines will upload to their Files API as needed)
849 if ( $isIMG ) {
850 $droppedFile = Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType );
851 }
852 else {
853 // For PDFs and documents, use refId so engines can access file data directly
854 $droppedFile = Meow_MWAI_Query_DroppedFile::from_refId( $internalRefId, 'analysis', $mimeType );
855 }
856
857 // IMPORTANT: Always use add_file() to add to attachedFiles array
858 // This is the unified approach for both single and multi-file uploads
859 // Engines will check attachedFiles array first, then fall back to attachedFile (legacy)
860 $query->add_file( $droppedFile );
861
862 // Update metadata using the internal refId (not OpenAI file ID)
863 $fileId = $this->core->files->get_id_from_refId( $internalRefId );
864 $this->core->files->update_envId( $fileId, $query->envId );
865 $this->core->files->update_purpose( $fileId, 'analysis' );
866 $this->core->files->add_metadata( $fileId, 'query_envId', $query->envId );
867 $this->core->files->add_metadata( $fileId, 'query_session', $query->session );
868 }
869 }
870 }
871
872 // Takeover
873 $takeoverAnswer = apply_filters( 'mwai_chatbot_takeover', null, $query, $params );
874 if ( !empty( $takeoverAnswer ) ) {
875 $reply = new Meow_MWAI_Reply( $query );
876 $reply->result = $takeoverAnswer;
877 $rawText = apply_filters( 'mwai_chatbot_reply', $takeoverAnswer, $reply, $params, [] );
878 return [
879 'reply' => $rawText,
880 'chatId' => $this->core->fix_chat_id( $query, $params ),
881 'images' => null,
882 'actions' => [],
883 'usage' => null
884 ];
885 }
886
887 // Moderation
888 $moderationEnabled = $this->core->get_option( 'module_moderation' ) &&
889 $this->core->get_option( 'shortcode_chat_moderation' );
890 if ( $moderationEnabled ) {
891 global $mwai;
892 $isFlagged = $mwai->moderationCheck( $query->get_message() );
893 if ( $isFlagged ) {
894 throw new Exception( 'Sorry, your message has been rejected by moderation.' );
895 }
896 }
897
898 // Setup streaming if enabled (before embeddings to capture those events)
899 $streamCallback = null;
900 $debugEvents = [];
901
902 if ( $stream ) {
903 $streamCallback = function ( $reply ) use ( $query ) {
904 // Support both legacy string data and new Event objects
905 if ( is_string( $reply ) ) {
906 $this->core->stream_push( [ 'type' => 'live', 'data' => $reply ], $query );
907 }
908 else {
909 $this->core->stream_push( $reply, $query );
910 }
911 };
912 if ( headers_sent( $filename, $linenum ) ) {
913 throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." );
914 }
915 header( 'Cache-Control: no-cache' );
916 header( 'Content-Type: text/event-stream' );
917 // This is useful to disable buffering in nginx through headers.
918 header( 'X-Accel-Buffering: no' );
919 ob_implicit_flush( true );
920 if ( ob_get_level() > 0 ) {
921 ob_end_flush();
922 }
923 }
924 else if ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ) {
925 // For non-streaming debug mode, collect events
926 $streamCallback = function ( $event ) use ( &$debugEvents ) {
927 if ( is_object( $event ) && method_exists( $event, 'toArray' ) ) {
928 $debugEvents[] = $event->toArray();
929 }
930 };
931 }
932
933 // Awareness & Embeddings
934 $context = $this->core->retrieve_context( $params, $query, $streamCallback );
935 if ( !empty( $context ) ) {
936 $query->set_context( $context['content'] );
937 }
938
939 // Function Aware
940 $query = apply_filters( 'mwai_chatbot_query', $query, $params );
941 }
942
943 // Process Query
944
945 $reply = $this->core->run_query( $query, $streamCallback, true );
946 $rawText = $reply->result;
947 $extra = [];
948 if ( $context ) {
949 $extra = [ 'embeddings' => isset( $context['embeddings'] ) ? $context['embeddings'] : null ];
950 }
951 // Store response ID for Responses API stateful conversations
952 // CRITICAL: Must store even when function calls are present
953 // This enables the feedback query to use previous_response_id
954 if ( !empty( $reply->id ) ) {
955 $extra['responseId'] = $reply->id;
956 $extra['responseDate'] = gmdate( 'Y-m-d H:i:s' ); // Track age for 30-day expiry
957 }
958 $rawText = apply_filters( 'mwai_chatbot_reply', $rawText, $reply, $params, $extra );
959
960 // Integrity Check: We need to store the checksum of the messages sent by the client.
961 $stored_messages = $client_messages;
962 $stored_messages[] = [ 'role' => 'user', 'content' => $newMessage ];
963 $stored_messages[] = [ 'role' => 'assistant', 'content' => $rawText ];
964 $stored_checksum = $this->calculate_messages_checksum( $stored_messages );
965 set_transient( $checksum_key, $stored_checksum, 60 * 60 * 24 * 30 );
966
967 // Actions
968 $actions = [];
969 if ( $reply->needClientActions ) {
970 foreach ( $reply->needClientActions as $action ) {
971 $actions[] = [
972 'type' => 'function',
973 'data' => [
974 'name' => $action['function']->name,
975 'args' => $action['arguments']
976 ]
977 ];
978 }
979 }
980
981 $restRes = [
982 'reply' => $rawText,
983 'chatId' => $this->core->fix_chat_id( $query, $params ),
984 'images' => $reply->get_type() === 'images' ? $reply->results : null,
985 'actions' => $actions,
986 'usage' => $reply->usage
987 ];
988
989 // Add debug events if collected
990 if ( !empty( $debugEvents ) ) {
991 $restRes['debugEvents'] = $debugEvents;
992 }
993
994 // Add response ID if available (for Responses API)
995 if ( !empty( $reply->id ) ) {
996 $restRes['responseId'] = $reply->id;
997 }
998
999 // Process Reply
1000 if ( $stream ) {
1001 $final_res = $this->build_final_res(
1002 $botId,
1003 $newMessage,
1004 $newFileId,
1005 $params,
1006 $restRes['reply'],
1007 $restRes['images'],
1008 $restRes['actions'],
1009 $restRes['usage'],
1010 $restRes['responseId'] ?? null
1011 );
1012 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query );
1013 die();
1014 }
1015 else {
1016 return $restRes;
1017 }
1018
1019 }
1020 catch ( Exception $e ) {
1021 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1022 if ( $stream ) {
1023 $this->core->stream_push( [ 'type' => 'error', 'data' => $message ], $query );
1024 die();
1025 }
1026 else {
1027 throw $e;
1028 }
1029 }
1030 }
1031
1032 public function inject_chat() {
1033 $params = $this->core->get_chatbot( $this->siteWideChatId );
1034 $clean_params = [];
1035 if ( !empty( $params ) ) {
1036 $clean_params['window'] = true;
1037 $clean_params['id'] = $this->siteWideChatId;
1038 echo $this->chat_shortcode( $clean_params );
1039 }
1040 return null;
1041 }
1042
1043 public function build_front_params( $botId, $customId, $crossSite = false ) {
1044 $frontSystem = [
1045 'botId' => ( $customId && $customId !== '' ) ? null : sanitize_text_field( $botId ),
1046 'customId' => ( $customId && $customId !== '' ) ? sanitize_text_field( $customId ) : null,
1047 'userData' => $this->core->get_user_data(),
1048 // For logged-out users we deliberately do NOT embed a sessionId at HTML
1049 // render time. Page caches (WP Rocket, Cloudflare, Varnish, etc.) and
1050 // multi-backend setups would otherwise freeze one sessionId into the
1051 // cached markup and serve it to every visitor — collapsing per-visitor
1052 // rate limits, file ownership, and stats. The frontend fetches a fresh
1053 // sessionId via /start_session on first interaction, and the server
1054 // always derives session from the mwai_session_id cookie anyway
1055 // (Query_Base::__construct + inject_params ignore empty client values).
1056 'sessionId' => is_user_logged_in() ? $this->core->get_session_id() : null,
1057 // IMPORTANT: REST nonce handling differs by user state:
1058 // - Logged-in users: get_nonce() returns a user-specific nonce created in current session context
1059 // - Logged-out users: get_nonce() returns null, they'll fetch via /start_session endpoint
1060 // This prevents rest_cookie_invalid_nonce errors for logged-in users by ensuring the nonce
1061 // matches their authentication context from the start.
1062 'restNonce' => $crossSite ? null : $this->core->get_nonce(),
1063 'contextId' => is_singular() ? get_the_ID() : null,
1064 'pluginUrl' => untrailingslashit( MWAI_URL ),
1065 'restUrl' => untrailingslashit( get_rest_url() ),
1066 'stream' => $this->core->get_option( 'ai_streaming' ),
1067 'debugMode' => $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ),
1068 'eventLogs' => $this->core->get_option( 'event_logs' ),
1069 'speech_recognition' => $this->core->get_option( 'speech_recognition' ),
1070 'speech_synthesis' => $this->core->get_option( 'speech_synthesis' ),
1071 'typewriter' => $this->core->get_option( 'chatbot_typewriter' ),
1072 'crossSite' => $crossSite
1073 ];
1074 return $frontSystem;
1075 }
1076
1077 public function resolveBotInfo( &$atts ) {
1078 $chatbot = null;
1079 $botId = $atts['id'] ?? null;
1080 $customId = $atts['custom_id'] ?? null;
1081 $parentBotId = null;
1082
1083 if ( !$botId && !$customId ) {
1084 $botId = 'default';
1085 }
1086 if ( $botId ) {
1087 $chatbot = $this->core->get_chatbot( $botId );
1088 if ( !$chatbot ) {
1089 $botId = $botId ?: 'N/A';
1090 $safe_botId = esc_html( $botId );
1091 return [
1092 'error' => "AI Engine: Chatbot '{$safe_botId}' not found. If you meant to set an ID for your custom chatbot, please use 'custom_id' instead of 'id'.",
1093 ];
1094 }
1095 }
1096 $chatbot = $chatbot ?: $this->core->get_chatbot( 'default' );
1097
1098 if ( !empty( $customId ) ) {
1099 if ( $botId !== null ) {
1100 $parentBotId = $botId;
1101 $botId = null;
1102 }
1103 }
1104
1105 unset( $atts['id'] );
1106 return [
1107 'chatbot' => $chatbot,
1108 'botId' => $botId,
1109 'customId' => $customId,
1110 'parentBotId' => $parentBotId
1111 ];
1112 }
1113
1114 public function chat_shortcode( $atts ) {
1115 $atts = empty( $atts ) ? [] : $atts;
1116
1117 foreach ( $atts as $key => $value ) {
1118 $atts[ $key ] = urldecode( $value );
1119 }
1120
1121 // Let the user override the chatbot params
1122 $atts = apply_filters( 'mwai_chatbot_params', $atts );
1123
1124 // Resolve the bot info
1125 $resolvedBot = $this->resolveBotInfo( $atts );
1126 if ( isset( $resolvedBot['error'] ) ) {
1127 return $resolvedBot['error'];
1128 }
1129 $chatbot = $resolvedBot['chatbot'];
1130 $botId = $resolvedBot['botId'];
1131 $customId = $resolvedBot['customId'];
1132 $parentBotId = $resolvedBot['parentBotId'];
1133
1134 // Rename the keys of the atts into camelCase to match the internal params system.
1135 $atts = array_map( function ( $key, $value ) {
1136 $key = str_replace( '_', ' ', $key );
1137 $key = ucwords( $key );
1138 $key = str_replace( ' ', '', $key );
1139 $key = lcfirst( $key );
1140 return [ $key => $value ];
1141 }, array_keys( $atts ), $atts );
1142 $atts = array_merge( ...$atts );
1143
1144 if ( !empty( $parentBotId ) ) {
1145 $atts['parentBotId'] = $parentBotId;
1146 }
1147
1148 $frontParams = [];
1149 // Define text parameters that need sanitization (excluding those that support HTML)
1150 $textParams = ['aiName', 'userName', 'guestName', 'textSend', 'textClear', 'textInputPlaceholder',
1151 'startSentence', 'iconText', 'iconAlt', 'headerSubtitle', 'popupTitle', 'allowedMimeTypes', 'maxHeight', 'iconSize'];
1152 // Parameters that support HTML content
1153 $htmlParams = ['textCompliance'];
1154 // Boolean parameters that need special handling
1155 $booleanParams = ['window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1156 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1157
1158 foreach ( MWAI_CHATBOT_FRONT_PARAMS as $param ) {
1159 // Let's go through the overriden or custom params first (the ones passed in the shortcode)
1160 if ( isset( $atts[$param] ) ) {
1161 if ( $param === 'localMemory' ) {
1162 $frontParams[$param] = $atts[$param] === 'true';
1163 }
1164 else if ( in_array( $param, $textParams ) ) {
1165 // Sanitize text parameters to prevent XSS
1166 $frontParams[$param] = sanitize_text_field( $atts[$param] );
1167 }
1168 else if ( in_array( $param, $htmlParams ) ) {
1169 // For HTML parameters, use wp_kses_post to allow safe HTML
1170 $frontParams[$param] = wp_kses_post( $atts[$param] );
1171 }
1172 else if ( in_array( $param, $booleanParams ) ) {
1173 // Convert to proper boolean
1174 // Handle various boolean representations from shortcode attributes
1175 $value = $atts[$param];
1176 if ( is_bool( $value ) ) {
1177 $frontParams[$param] = $value;
1178 }
1179 else if ( is_string( $value ) ) {
1180 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0' && $value !== 'no';
1181 }
1182 else {
1183 $frontParams[$param] = !empty( $value );
1184 }
1185 }
1186 else {
1187 $frontParams[$param] = $atts[$param];
1188 }
1189 }
1190 // If not, let's use the chatbot's default values
1191 else if ( isset( $chatbot[$param] ) ) {
1192 if ( in_array( $param, $booleanParams ) ) {
1193 // Convert to proper boolean for chatbot defaults too
1194 // Handle various boolean representations
1195 $value = $chatbot[$param];
1196
1197 if ( is_bool( $value ) ) {
1198 $frontParams[$param] = $value;
1199 }
1200 else if ( is_string( $value ) ) {
1201 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0';
1202 }
1203 else {
1204 $frontParams[$param] = !empty( $value );
1205 }
1206 }
1207 else {
1208 $frontParams[$param] = $chatbot[$param];
1209 }
1210 }
1211
1212 // Apply the placeholders
1213 if ( in_array( $param, ['startSentence', 'iconText'] ) ) {
1214 $frontParams[$param] = $this->core->do_placeholders( $frontParams[$param] );
1215 }
1216 }
1217
1218 // Ensure upload params are synced
1219 // fileUpload (checkbox) determines if uploads are enabled
1220 // maxUploads (number) determines how many files can be uploaded
1221 $fileUploadEnabled = !empty( $frontParams['fileUpload'] ) || !empty( $frontParams['imageUpload'] );
1222 $maxFiles = isset( $frontParams['maxUploads'] ) ? max( 1, (int) $frontParams['maxUploads'] ) : 1;
1223
1224 // Sync all params for backward compatibility
1225 $frontParams['fileUpload'] = $fileUploadEnabled;
1226 $frontParams['imageUpload'] = $fileUploadEnabled;
1227 $frontParams['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1228 $frontParams['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1229 $frontParams['maxUploads'] = $maxFiles;
1230
1231 // Server Params
1232 // NOTE: We don't need the server params for the chatbot if there are no overrides, it means
1233 // we are using the default or a specific chatbot.
1234 $isSiteWide = $this->siteWideChatId && $botId === $this->siteWideChatId;
1235
1236 // Parameters that are purely visual/UI and shouldn't trigger custom ID
1237 $visualOnlyParams = [
1238 // Bot selectors
1239 'id', 'custom_id',
1240 // System-added params
1241 'crossSite',
1242 // Visual/UI parameters that don't affect AI behavior
1243 'aiName', 'userName', 'guestName', // Display names
1244 'aiAvatar', 'userAvatar', 'guestAvatar', 'aiAvatarUrl', 'userAvatarUrl', 'guestAvatarUrl', // Avatars
1245 'textSend', 'textClear', 'textInputPlaceholder', 'textCompliance', // UI text labels
1246 'textInputMaxLength', // Input constraint (visual)
1247 'themeId', // Theme selection
1248 'window', 'icon', 'iconText', 'iconTextDelay', 'iconAlt', 'iconPosition', // Window/icon settings
1249 'centerOpen', 'width', 'openDelay', 'iconBubble', 'windowAnimation', 'fullscreen', // Window behavior
1250 'copyButton', 'pdfButton', 'headerSubtitle', 'popupTitle', // UI features
1251 'containerType', 'headerType', 'messagesType', 'inputType', 'footerType' // UI style variants
1252 ];
1253
1254 // Remove visual-only params from override detection
1255 $attsForOverrideCheck = array_diff_key( $atts, array_flip( $visualOnlyParams ) );
1256
1257 // Only these front params affect behavior and should trigger custom ID:
1258 // - mode: chat vs. prompt mode
1259 // - startSentence: initial AI message
1260 // - localMemory: affects data persistence
1261 // - imageUpload, fileUpload, multiUpload, fileSearch: affect capabilities
1262 $behavioralFrontParams = ['mode', 'startSentence', 'localMemory', 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1263
1264 $hasServerOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), MWAI_CHATBOT_SERVER_PARAMS ) ) > 0;
1265 $hasBehavioralFrontOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), $behavioralFrontParams ) ) > 0;
1266 $hasOverrides = !$isSiteWide && ( $hasServerOverrides || $hasBehavioralFrontOverrides );
1267
1268 $serverParams = [];
1269 if ( $hasOverrides ) {
1270 // Server parameters don't need sanitization as they're processed server-side
1271 // and not rendered in HTML. They may contain code, HTML, etc. for AI context.
1272 foreach ( MWAI_CHATBOT_SERVER_PARAMS as $param ) {
1273 if ( isset( $atts[$param] ) ) {
1274 $serverParams[$param] = $atts[$param];
1275 }
1276 else {
1277 // For custom chatbots, don't inherit embeddingsEnvId from the default chatbot
1278 if ( $param === 'embeddingsEnvId' && !empty( $customId ) ) {
1279 $serverParams[$param] = '';
1280 }
1281 else {
1282 $serverParams[$param] = $chatbot[$param] ?? null;
1283 }
1284 }
1285 }
1286 }
1287
1288 // Front Params
1289 $frontSystem = $this->build_front_params( $botId, $customId );
1290
1291 // Clean Params
1292 $frontParams = $this->clean_params( $frontParams );
1293 $frontSystem = $this->clean_params( $frontSystem );
1294 $serverParams = $this->clean_params( $serverParams );
1295
1296 // Server-side: Keep the System Params
1297 if ( $hasOverrides ) {
1298 if ( empty( $customId ) ) {
1299 $customId = md5( json_encode( $serverParams ) );
1300 $frontSystem['customId'] = $customId;
1301 }
1302 set_transient( 'mwai_custom_chatbot_' . $customId, $serverParams, 60 * 60 * 24 );
1303 }
1304
1305 // Retrieve the actions, shortcuts, and blocks we want to inject at the beginning
1306 $filterParams = [
1307 'step' => 'init',
1308 'botId' => $botId,
1309 'params' => array_merge( $frontParams, $frontSystem, $serverParams )
1310 ];
1311 $actions = apply_filters( 'mwai_chatbot_actions', [], $filterParams );
1312 $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams );
1313 $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams );
1314 $frontSystem['actions'] = $this->sanitize_actions( $actions );
1315 $frontSystem['blocks'] = $this->sanitize_blocks( $blocks );
1316 $shortcuts = $this->sanitize_shortcuts( $shortcuts );
1317 $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
1318 $frontSystem['shortcuts'] = $shortcuts;
1319
1320 // Client-side: Prepare JSON for Front Params and System Params
1321 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1322 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1323 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1324 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1325 //$jsonAttributes = htmlspecialchars(json_encode($atts), ENT_QUOTES, 'UTF-8');
1326
1327 $this->enqueue_scripts( $frontParams['themeId'] ?? null );
1328
1329 return "<div class='mwai-chatbot-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1330 }
1331
1332 public function chatbot_discussions( $atts ) {
1333 $atts = empty( $atts ) ? [] : $atts;
1334
1335 // Resolve the bot info
1336 $resolvedBot = $this->resolveBotInfo( $atts );
1337 if ( isset( $resolvedBot['error'] ) ) {
1338 return $resolvedBot['error'];
1339 }
1340 $chatbot = $resolvedBot['chatbot'];
1341 $botId = $resolvedBot['botId'];
1342 $customId = $resolvedBot['customId'];
1343
1344 // Rename the keys of the atts into camelCase to match the internal params system.
1345 $atts = array_map( function ( $key, $value ) {
1346 $key = str_replace( '_', ' ', $key );
1347 $key = ucwords( $key );
1348 $key = str_replace( ' ', '', $key );
1349 $key = lcfirst( $key );
1350 return [ $key => $value ];
1351 }, array_keys( $atts ), $atts );
1352 $atts = array_merge( ...$atts );
1353
1354 // Front Params
1355 $frontParams = [];
1356 // All discussion params are text params that need sanitization
1357 $textParams = ['textNewChat'];
1358
1359 foreach ( MWAI_DISCUSSIONS_FRONT_PARAMS as $param ) {
1360 if ( isset( $atts[$param] ) ) {
1361 // Sanitize text parameters
1362 $frontParams[$param] = in_array( $param, $textParams ) ? sanitize_text_field( $atts[$param] ) : $atts[$param];
1363 }
1364 else if ( isset( $chatbot[$param] ) ) {
1365 $frontParams[$param] = $chatbot[$param];
1366 }
1367 }
1368
1369 // Server Params
1370 $serverParams = [];
1371 foreach ( MWAI_DISCUSSIONS_SERVER_PARAMS as $param ) {
1372 if ( isset( $atts[$param] ) ) {
1373 $serverParams[$param] = $atts[$param];
1374 }
1375 }
1376
1377 // Front System
1378 $frontSystem = $this->build_front_params( $botId, $customId );
1379 // Get refresh interval from settings
1380 $refresh_interval = $this->core->get_option( 'chatbot_discussions_refresh_interval' );
1381 if ( $refresh_interval === 'Never' ) {
1382 $frontSystem['refreshInterval'] = 0;
1383 }
1384 elseif ( $refresh_interval === 'Manual' ) {
1385 $frontSystem['refreshInterval'] = -1;
1386 }
1387 elseif ( is_numeric( $refresh_interval ) ) {
1388 $frontSystem['refreshInterval'] = intval( $refresh_interval ) * 1000; // Convert to milliseconds
1389 }
1390 else {
1391 $frontSystem['refreshInterval'] = 5000; // Default to 5 seconds
1392 }
1393 $frontSystem['refreshInterval'] = apply_filters( 'mwai_discussions_refresh_interval', $frontSystem['refreshInterval'] );
1394
1395 // Get paging setting
1396 $paging_option = $this->core->get_option( 'chatbot_discussions_paging' );
1397 if ( $paging_option === 'None' ) {
1398 $frontSystem['paging'] = 0; // No pagination
1399 }
1400 else {
1401 $frontSystem['paging'] = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Default to 10
1402 }
1403
1404 // Get metadata settings
1405 $frontSystem['metadata'] = [
1406 'enabled' => $this->core->get_option( 'chatbot_discussions_metadata_enabled' ),
1407 'startDate' => $this->core->get_option( 'chatbot_discussions_metadata_start_date' ),
1408 'lastUpdate' => $this->core->get_option( 'chatbot_discussions_metadata_last_update' ),
1409 'messageCount' => $this->core->get_option( 'chatbot_discussions_metadata_message_count' )
1410 ];
1411
1412 // Clean Params
1413 $frontParams = $this->clean_params( $frontParams );
1414 $frontSystem = $this->clean_params( $frontSystem );
1415 $serverParams = $this->clean_params( $serverParams );
1416
1417 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1418 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1419 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1420 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1421
1422 return "<div class='mwai-discussions-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1423 }
1424
1425 public function clean_params( &$params ) {
1426 foreach ( $params as $param => $value ) {
1427 if ( $param === 'restNonce' ) {
1428 continue;
1429 }
1430 // Skip only if value is null or an array - but not if it's false or 0
1431 if ( is_null( $value ) || is_array( $value ) ) {
1432 continue;
1433 }
1434 // Handle empty strings
1435 if ( $value === '' ) {
1436 continue;
1437 }
1438 $lowerCaseValue = is_string( $value ) ? strtolower( $value ) : '';
1439 if ( $lowerCaseValue === 'true' || $lowerCaseValue === 'false' || is_bool( $value ) ) {
1440 $params[$param] = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
1441 }
1442 else if ( is_numeric( $value ) ) {
1443 $params[$param] = filter_var( $value, FILTER_VALIDATE_FLOAT );
1444 }
1445 }
1446 return $params;
1447 }
1448
1449 }
1450