PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.3
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.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 7 months ago chatbot.php 5 months ago discussions.php 5 months ago files.php 6 months ago forms-manager.php 10 months ago gdpr.php 11 months ago search.php 1 year ago security.php 1 year ago tasks-examples.php 6 months ago tasks.php 5 months ago wand.php 5 months ago
chatbot.php
1393 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', 'centerOpen', 'width', 'openDelay', 'iconBubble', 'windowAnimation', 'fullscreen', 'copyButton', '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 * Prepare shortcuts for client by replacing messages with shortcutIds.
330 * The actual messages are stored server-side and looked up when the shortcut is clicked.
331 * This keeps the prompt content private and not exposed in the browser.
332 *
333 * @param array $shortcuts The shortcuts to prepare.
334 * @param string $botId The bot ID for validation.
335 * @return array The prepared shortcuts with shortcutIds instead of messages.
336 */
337 public function prepare_shortcuts_for_client( $shortcuts, $botId ) {
338 if ( empty( $shortcuts ) ) {
339 return $shortcuts;
340 }
341
342 $prepared = [];
343 foreach ( $shortcuts as $shortcut ) {
344 $type = $shortcut['type'] ?? '';
345 $data = $shortcut['data'] ?? [];
346
347 // Only process shortcuts that have a message (not callbacks)
348 if ( isset( $data['message'] ) && !empty( $data['message'] ) ) {
349 // Generate a unique shortcut ID
350 $shortcutId = wp_generate_uuid4();
351
352 // Store the message server-side (transient with 1 hour expiry)
353 $transient_key = 'mwai_shortcut_' . $shortcutId;
354 set_transient( $transient_key, [
355 'message' => $data['message'],
356 'botId' => $botId,
357 ], HOUR_IN_SECONDS );
358
359 // Replace message with shortcutId
360 unset( $data['message'] );
361 $data['shortcutId'] = $shortcutId;
362 }
363
364 $prepared[] = [
365 'type' => $type,
366 'data' => $data,
367 ];
368 }
369
370 return $prepared;
371 }
372
373 /**
374 * Look up a shortcut message by its ID.
375 *
376 * @param string $shortcutId The shortcut ID.
377 * @param string $botId The bot ID for validation.
378 * @return string|null The message, or null if not found.
379 */
380 public function get_shortcut_message( $shortcutId, $botId ) {
381 if ( empty( $shortcutId ) ) {
382 return null;
383 }
384
385 $transient_key = 'mwai_shortcut_' . $shortcutId;
386 $shortcut_data = get_transient( $transient_key );
387
388 if ( !$shortcut_data || !isset( $shortcut_data['message'] ) ) {
389 return null;
390 }
391
392 // Validate botId matches (security check)
393 if ( isset( $shortcut_data['botId'] ) && $shortcut_data['botId'] !== $botId ) {
394 Meow_MWAI_Logging::warn( "Shortcut botId mismatch: expected {$shortcut_data['botId']}, got {$botId}" );
395 return null;
396 }
397
398 return $shortcut_data['message'];
399 }
400
401 #region Messages Integrity Check
402
403 public function messages_integrity_diff( $messages1, $messages2 ) {
404 // Ensure both parameters are arrays
405 if ( !is_array( $messages1 ) ) {
406 $messages1 = [];
407 }
408 if ( !is_array( $messages2 ) ) {
409 $messages2 = [];
410 }
411
412 // Collect messages with role not 'user' from messages1
413 $messagesList1 = [];
414 foreach ( $messages1 as $msg ) {
415 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
416 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
417 if ( $role && $role != 'user' ) {
418 $messageData = [ 'role' => $role, 'content' => $content ];
419 $messagesList1[] = $messageData;
420 }
421 }
422
423 // Collect messages with role not 'user' from messages2
424 $messagesList2 = [];
425 foreach ( $messages2 as $msg ) {
426 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
427 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
428 if ( $role && $role != 'user' ) {
429 $messageData = [ 'role' => $role, 'content' => $content ];
430 $messagesList2[] = $messageData;
431 }
432 }
433
434 // Count occurrences of each message in messagesList1
435 $counts1 = [];
436 foreach ( $messagesList1 as $msg ) {
437 $key = serialize( $msg );
438 if ( isset( $counts1[ $key ] ) ) {
439 $counts1[ $key ]++;
440 }
441 else {
442 $counts1[ $key ] = 1;
443 }
444 }
445
446 // Count occurrences of each message in messagesList2
447 $counts2 = [];
448 foreach ( $messagesList2 as $msg ) {
449 $key = serialize( $msg );
450 if ( isset( $counts2[ $key ] ) ) {
451 $counts2[ $key ]++;
452 }
453 else {
454 $counts2[ $key ] = 1;
455 }
456 }
457
458 // Compare counts to find unmatched messages
459 $all_keys = array_unique( array_merge( array_keys( $counts1 ), array_keys( $counts2 ) ) );
460
461 $diffs = [];
462 foreach ( $all_keys as $key ) {
463 $count1 = isset( $counts1[ $key ] ) ? $counts1[ $key ] : 0;
464 $count2 = isset( $counts2[ $key ] ) ? $counts2[ $key ] : 0;
465 if ( $count1 != $count2 ) {
466 $message = unserialize( $key );
467 $diffs[] = [
468 'message' => $message,
469 'count_in_messages1' => $count1,
470 'count_in_messages2' => $count2
471 ];
472 }
473 }
474
475 return $diffs;
476 }
477
478 private function calculate_messages_checksum( $messages ) {
479 $messages_to_hash = [];
480 foreach ( $messages as $msg ) {
481 $role = is_array( $msg ) ? ( $msg['role'] ?? '' ) : ( is_object( $msg ) ? ( $msg->role ?? '' ) : '' );
482 $content = is_array( $msg ) ? ( $msg['content'] ?? '' ) : ( is_object( $msg ) ? ( $msg->content ?? '' ) : '' );
483 if ( in_array( $role, ['assistant', 'system'] ) ) {
484 $messages_to_hash[] = [ 'role' => $role, 'content' => $content ];
485 }
486 }
487 return md5( json_encode( $messages_to_hash ) );
488 }
489
490 #endregion
491
492 public function chat_submit( $botId, $newMessage, $newFileId = null, $params = [], $stream = false, $newFileIds = [] ) {
493 $query = null; // Initialize query variable to avoid undefined variable errors
494 try {
495 $chatbot = null;
496 $customId = $params['customId'] ?? null;
497
498 // Custom Chatbot
499 if ( $customId ) {
500 $chatbot = get_transient( 'mwai_custom_chatbot_' . $customId );
501 }
502 // Registered Chatbot
503 if ( !$chatbot && $botId ) {
504 $chatbot = $this->core->get_chatbot( $botId );
505 }
506 // Fall back to default chatbot if no chatbot found yet
507 if ( !$chatbot ) {
508 $chatbot = $this->core->get_chatbot( 'default' );
509 }
510
511 if ( !$chatbot ) {
512 Meow_MWAI_Logging::warn( 'The query was rejected - no chatbot was found.' );
513 throw new Exception( 'Sorry, your query has been rejected.' );
514 }
515
516 $textInputMaxLength = $chatbot['textInputMaxLength'] ?? null;
517 if ( $textInputMaxLength && $this->core->safe_strlen( $newMessage ) > (int) $textInputMaxLength ) {
518 Meow_MWAI_Logging::warn( 'The query was rejected - message was too long.' );
519 throw new Exception( 'Sorry, your query has been rejected.' );
520 }
521
522 // We need to check the integrity of the messages sent by the client.
523 // This is important to ensure that the messages are not tampered with.
524
525 // Messages Integrity Check with Checksums
526 $chatId = $params['chatId'] ?? 'default';
527 $checksum_key = 'mwai_chatbot_checksum_' . $chatId;
528 $stored_checksum = get_transient( $checksum_key );
529 $client_messages = $params['messages'] ?? [];
530 $client_checksum = $this->calculate_messages_checksum( $client_messages );
531 if ( $stored_checksum && $stored_checksum !== $client_checksum ) {
532 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.' );
533 }
534
535 // Messages Integrity Check with Discussions
536 if ( $this->core->get_option( 'chatbot_discussions' ) && $this->core->discussions && isset( $params['chatId'] ) ) {
537 $discussion = $this->core->discussions->get_discussion( $botId ? $botId : $customId, $params['chatId'] );
538 if ( $discussion ) {
539 $messages = $discussion['messages'];
540 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
541 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
542 if ( count( $diffs ) > 0 ) {
543 Meow_MWAI_Logging::warn( "Integrity Check: It seems the messages in the discussion #{$discussion['id']} do not match the ones sent by the client." );
544 }
545
546 // Maintain conversation state for Responses API by loading previousResponseId
547 // This enables stateful conversations where only new messages are sent
548 if ( empty( $params['previousResponseId'] ) && !empty( $discussion['extra'] ) ) {
549 $extra = json_decode( $discussion['extra'], true );
550 if ( !empty( $extra['responseId'] ) ) {
551 // Response IDs expire after 30 days per OpenAI's policy
552 // Check if the stored response is still valid
553 $responseDate = !empty( $extra['responseDate'] ) ? strtotime( $extra['responseDate'] ) : 0;
554 $thirtyDaysAgo = time() - ( 30 * 24 * 60 * 60 );
555
556 if ( $responseDate > $thirtyDaysAgo ) {
557 // Use the stored response ID for stateful conversation
558 $params['previousResponseId'] = $extra['responseId'];
559 }
560 }
561 }
562 }
563 else {
564 // No discussion yet? We still need to check the startSentence.
565 $startSentence = isset( $chatbot['startSentence'] ) ? $chatbot['startSentence'] : null;
566 $messages = [];
567 if ( !empty( $startSentence ) ) {
568 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
569 }
570 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
571 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
572 if ( count( $diffs ) > 0 ) {
573 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 ) );
574 }
575 }
576 }
577
578 // Create QueryText
579 $context = null;
580 $streamCallback = null;
581 $mode = $chatbot['mode'] ?? 'chat';
582
583 if ( $mode === 'images' ) {
584 // Check for uploaded files
585 $fileForImage = null;
586 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
587 $fileForImage = $newFileIds[0];
588 }
589 elseif ( !empty( $newFileId ) ) {
590 $fileForImage = $newFileId;
591 }
592
593 // If there's an uploaded file, use EditImage query instead
594 if ( !empty( $fileForImage ) ) {
595 $query = new Meow_MWAI_Query_EditImage( $newMessage );
596
597 // Handle the uploaded image
598 $url = $this->core->files->get_url( $fileForImage );
599 $mimeType = $this->core->files->get_mime_type( $fileForImage );
600 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
601
602 if ( $isIMG ) {
603 $query->add_file( Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType ) );
604 $fileId = $this->core->files->get_id_from_refId( $fileForImage );
605 $this->core->files->update_purpose( $fileId, 'analysis' );
606 }
607 }
608 else {
609 $query = new Meow_MWAI_Query_Image( $newMessage );
610 }
611
612 // Handle Params
613 $newParams = [];
614 foreach ( $chatbot as $key => $value ) {
615 $newParams[$key] = $value;
616 }
617 if ( is_array( $params ) ) {
618 foreach ( $params as $key => $value ) {
619 $newParams[$key] = $value;
620 }
621 }
622
623 // Map 'environment' field to 'envId' for compatibility
624 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
625 $newParams['envId'] = $newParams['environment'];
626 }
627
628 $params = apply_filters( 'mwai_chatbot_params', $newParams );
629 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
630
631 // Debug log for embeddings
632 if ( !empty( $params['embeddingsEnvId'] ) ) {
633 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
634 }
635 else {
636 // Log all params to debug
637 $paramKeys = array_keys( $params );
638 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
639 }
640
641 $query->inject_params( $params );
642 }
643 else {
644 $query = $mode === 'assistant' ? new Meow_MWAI_Query_Assistant( $newMessage ) :
645 new Meow_MWAI_Query_Text( $newMessage, 4096 );
646
647 // Handle Params
648 $newParams = [];
649 foreach ( $chatbot as $key => $value ) {
650 $newParams[$key] = $value;
651 }
652 if ( is_array( $params ) ) {
653 foreach ( $params as $key => $value ) {
654 $newParams[$key] = $value;
655 }
656 }
657
658 // Map 'environment' field to 'envId' for compatibility
659 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
660 $newParams['envId'] = $newParams['environment'];
661 }
662
663 $params = apply_filters( 'mwai_chatbot_params', $newParams );
664 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
665
666 // Debug log for embeddings
667 if ( !empty( $params['embeddingsEnvId'] ) ) {
668 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
669 }
670 else {
671 // Log all params to debug
672 $paramKeys = array_keys( $params );
673 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
674 }
675
676 // In Prompt mode, clear out features that are not supported before injecting params
677 if ( $mode === 'prompt' ) {
678 // Clear embeddings/context settings
679 unset( $params['embeddingsEnvId'] );
680 unset( $params['embeddingsIndex'] );
681 unset( $params['embeddingsNamespace'] );
682 unset( $params['contentAware'] );
683 unset( $params['context'] );
684
685 // Clear function calling and MCP servers
686 unset( $params['functions'] );
687 unset( $params['mcpServers'] );
688
689 // Clear tools
690 unset( $params['tools'] );
691
692 // Clear temperature, reasoning, verbosity as they're configured in the prompt
693 unset( $params['temperature'] );
694 unset( $params['reasoningEffort'] );
695 unset( $params['verbosity'] );
696 unset( $params['maxTokens'] );
697 }
698
699 $query->inject_params( $params );
700
701 // Handle Prompt mode specifics
702 if ( $mode === 'prompt' && !empty( $params['promptId'] ) ) {
703 $promptData = [ 'id' => $params['promptId'] ];
704 $query->setExtraParam( 'prompt', $promptData );
705 }
706
707 $storeId = null;
708 if ( $mode === 'assistant' ) {
709 $chatId = $params['chatId'] ?? null;
710 if ( !empty( $chatId ) && $this->core->discussions ) {
711 $discussion = $this->core->discussions->get_discussion( $query->botId, $chatId );
712 if ( isset( $discussion['storeId'] ) ) {
713 $storeId = $discussion['storeId'];
714 $query->setStoreId( $storeId );
715 }
716 }
717 }
718
719 // Support for Multiple Uploaded Files
720 $filesToProcess = [];
721 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
722 $filesToProcess = $newFileIds;
723 }
724 elseif ( !empty( $newFileId ) ) {
725 $filesToProcess[] = $newFileId;
726 }
727
728 // Support for Uploaded Image/Files
729 if ( !empty( $filesToProcess ) ) {
730 // Process all files for multi-upload support
731 foreach ( $filesToProcess as $fileToProcess ) {
732 // Get extension and mime type
733 $isImage = $this->core->files->is_image( $fileToProcess );
734
735 if ( $mode === 'assistant' && !$isImage ) {
736 // DEPRECATED: Assistants API and File Search are deprecated
737 // After August 26, 2026, this entire block should be removed
738 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.' );
739
740 $url = $this->core->files->get_path( $fileToProcess );
741 $data = $this->core->files->get_data( $fileToProcess );
742 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId );
743 $filename = basename( $url );
744
745 // Upload the file
746 $file = $openai->upload_file( $filename, $data, 'assistants' );
747
748 // Create a store
749 if ( empty( $storeId ) ) {
750 $chatbotName = 'mwai_' . strtolower( !empty( $chatbot['name'] ) ? $chatbot['name'] : 'default' );
751 if ( !empty( $query->chatId ) ) {
752 $chatbotName .= '_' . $query->chatId;
753 }
754 $metadata = [];
755 if ( !empty( $chatbot['assistantId'] ) ) {
756 $metadata['assistantId'] = $chatbot['assistantId'];
757 }
758 if ( !empty( $query->chatId ) ) {
759 $metadata['chatId'] = $query->chatId;
760 }
761 $expiry = $this->core->get_option( 'image_expires' );
762 $storeId = $openai->create_vector_store( $chatbotName, $expiry, $metadata );
763 $query->setStoreId( $storeId );
764 }
765
766 // Add the file to the store - wait a moment for store to be ready
767 sleep( 1 );
768 $storeFileId = $openai->add_vector_store_file( $storeId, $file['id'] );
769
770 if ( empty( $storeFileId ) ) {
771 throw new Exception( 'Failed to add file to vector store.' );
772 }
773
774 // Update the local file with the OpenAI RefId, StoreId and StoreFileId
775 $openAiRefId = $file['id'];
776 $internalFileId = $this->core->files->get_id_from_refId( $fileToProcess );
777 $this->core->files->update_refId( $internalFileId, $openAiRefId );
778 $this->core->files->update_envId( $internalFileId, $query->envId );
779 $this->core->files->update_purpose( $internalFileId, 'analysis' );
780 $this->core->files->add_metadata( $internalFileId, 'assistant_storeId', $storeId );
781 $this->core->files->add_metadata( $internalFileId, 'assistant_storeFileId', $storeFileId );
782 $fileToProcess = $openAiRefId;
783 $scope = $params['fileSearch'];
784 if ( $scope === 'discussion' || $scope === 'user' || $scope === 'assistant' ) {
785 $id = $this->core->files->get_id_from_refId( $fileToProcess );
786 $this->core->files->add_metadata( $id, 'assistant_scope', $scope );
787 }
788 }
789 else {
790 // Keep track of the internal file ID (before any OpenAI processing)
791 // Important: $fileToProcess is our internal database refId, not OpenAI's file_id
792 $internalRefId = $fileToProcess;
793 $url = $this->core->files->get_url( $internalRefId );
794 $mimeType = $this->core->files->get_mime_type( $internalRefId );
795 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
796
797 // Create DroppedFile object - provider-agnostic approach
798 // Images use URL (can be sent as base64 or URL in messages)
799 // PDFs use refId (engines will upload to their Files API as needed)
800 if ( $isIMG ) {
801 $droppedFile = Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType );
802 }
803 else {
804 // For PDFs and documents, use refId so engines can access file data directly
805 $droppedFile = Meow_MWAI_Query_DroppedFile::from_refId( $internalRefId, 'analysis', $mimeType );
806 }
807
808 // IMPORTANT: Always use add_file() to add to attachedFiles array
809 // This is the unified approach for both single and multi-file uploads
810 // Engines will check attachedFiles array first, then fall back to attachedFile (legacy)
811 $query->add_file( $droppedFile );
812
813 // Update metadata using the internal refId (not OpenAI file ID)
814 $fileId = $this->core->files->get_id_from_refId( $internalRefId );
815 $this->core->files->update_envId( $fileId, $query->envId );
816 $this->core->files->update_purpose( $fileId, 'analysis' );
817 $this->core->files->add_metadata( $fileId, 'query_envId', $query->envId );
818 $this->core->files->add_metadata( $fileId, 'query_session', $query->session );
819 }
820 }
821 }
822
823 // Takeover
824 $takeoverAnswer = apply_filters( 'mwai_chatbot_takeover', null, $query, $params );
825 if ( !empty( $takeoverAnswer ) ) {
826 $reply = new Meow_MWAI_Reply( $query );
827 $reply->result = $takeoverAnswer;
828 $rawText = apply_filters( 'mwai_chatbot_reply', $takeoverAnswer, $reply, $params, [] );
829 return [
830 'reply' => $rawText,
831 'chatId' => $this->core->fix_chat_id( $query, $params ),
832 'images' => null,
833 'actions' => [],
834 'usage' => null
835 ];
836 }
837
838 // Moderation
839 $moderationEnabled = $this->core->get_option( 'module_moderation' ) &&
840 $this->core->get_option( 'shortcode_chat_moderation' );
841 if ( $moderationEnabled ) {
842 global $mwai;
843 $isFlagged = $mwai->moderationCheck( $query->get_message() );
844 if ( $isFlagged ) {
845 throw new Exception( 'Sorry, your message has been rejected by moderation.' );
846 }
847 }
848
849 // Setup streaming if enabled (before embeddings to capture those events)
850 $streamCallback = null;
851 $debugEvents = [];
852
853 if ( $stream ) {
854 $streamCallback = function ( $reply ) use ( $query ) {
855 // Support both legacy string data and new Event objects
856 if ( is_string( $reply ) ) {
857 $this->core->stream_push( [ 'type' => 'live', 'data' => $reply ], $query );
858 }
859 else {
860 $this->core->stream_push( $reply, $query );
861 }
862 };
863 if ( headers_sent( $filename, $linenum ) ) {
864 throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." );
865 }
866 header( 'Cache-Control: no-cache' );
867 header( 'Content-Type: text/event-stream' );
868 // This is useful to disable buffering in nginx through headers.
869 header( 'X-Accel-Buffering: no' );
870 ob_implicit_flush( true );
871 if ( ob_get_level() > 0 ) {
872 ob_end_flush();
873 }
874 }
875 else if ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ) {
876 // For non-streaming debug mode, collect events
877 $streamCallback = function ( $event ) use ( &$debugEvents ) {
878 if ( is_object( $event ) && method_exists( $event, 'toArray' ) ) {
879 $debugEvents[] = $event->toArray();
880 }
881 };
882 }
883
884 // Awareness & Embeddings
885 $context = $this->core->retrieve_context( $params, $query, $streamCallback );
886 if ( !empty( $context ) ) {
887 $query->set_context( $context['content'] );
888 }
889
890 // Function Aware
891 $query = apply_filters( 'mwai_chatbot_query', $query, $params );
892 }
893
894 // Process Query
895
896 $reply = $this->core->run_query( $query, $streamCallback, true );
897 $rawText = $reply->result;
898 $extra = [];
899 if ( $context ) {
900 $extra = [ 'embeddings' => isset( $context['embeddings'] ) ? $context['embeddings'] : null ];
901 }
902 // Store response ID for Responses API stateful conversations
903 // CRITICAL: Must store even when function calls are present
904 // This enables the feedback query to use previous_response_id
905 if ( !empty( $reply->id ) ) {
906 $extra['responseId'] = $reply->id;
907 $extra['responseDate'] = gmdate( 'Y-m-d H:i:s' ); // Track age for 30-day expiry
908 }
909 $rawText = apply_filters( 'mwai_chatbot_reply', $rawText, $reply, $params, $extra );
910
911 // Integrity Check: We need to store the checksum of the messages sent by the client.
912 $stored_messages = $client_messages;
913 $stored_messages[] = [ 'role' => 'user', 'content' => $newMessage ];
914 $stored_messages[] = [ 'role' => 'assistant', 'content' => $rawText ];
915 $stored_checksum = $this->calculate_messages_checksum( $stored_messages );
916 set_transient( $checksum_key, $stored_checksum, 60 * 60 * 24 * 30 );
917
918 // Actions
919 $actions = [];
920 if ( $reply->needClientActions ) {
921 foreach ( $reply->needClientActions as $action ) {
922 $actions[] = [
923 'type' => 'function',
924 'data' => [
925 'name' => $action['function']->name,
926 'args' => $action['arguments']
927 ]
928 ];
929 }
930 }
931
932 $restRes = [
933 'reply' => $rawText,
934 'chatId' => $this->core->fix_chat_id( $query, $params ),
935 'images' => $reply->get_type() === 'images' ? $reply->results : null,
936 'actions' => $actions,
937 'usage' => $reply->usage
938 ];
939
940 // Add debug events if collected
941 if ( !empty( $debugEvents ) ) {
942 $restRes['debugEvents'] = $debugEvents;
943 }
944
945 // Add response ID if available (for Responses API)
946 if ( !empty( $reply->id ) ) {
947 $restRes['responseId'] = $reply->id;
948 }
949
950 // Process Reply
951 if ( $stream ) {
952 $final_res = $this->build_final_res(
953 $botId,
954 $newMessage,
955 $newFileId,
956 $params,
957 $restRes['reply'],
958 $restRes['images'],
959 $restRes['actions'],
960 $restRes['usage'],
961 $restRes['responseId'] ?? null
962 );
963 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query );
964 die();
965 }
966 else {
967 return $restRes;
968 }
969
970 }
971 catch ( Exception $e ) {
972 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
973 if ( $stream ) {
974 $this->core->stream_push( [ 'type' => 'error', 'data' => $message ], $query );
975 die();
976 }
977 else {
978 throw $e;
979 }
980 }
981 }
982
983 public function inject_chat() {
984 $params = $this->core->get_chatbot( $this->siteWideChatId );
985 $clean_params = [];
986 if ( !empty( $params ) ) {
987 $clean_params['window'] = true;
988 $clean_params['id'] = $this->siteWideChatId;
989 echo $this->chat_shortcode( $clean_params );
990 }
991 return null;
992 }
993
994 public function build_front_params( $botId, $customId, $crossSite = false ) {
995 $frontSystem = [
996 'botId' => ( $customId && $customId !== '' ) ? null : sanitize_text_field( $botId ),
997 'customId' => ( $customId && $customId !== '' ) ? sanitize_text_field( $customId ) : null,
998 'userData' => $this->core->get_user_data(),
999 'sessionId' => $this->core->get_session_id(),
1000 // IMPORTANT: REST nonce handling differs by user state:
1001 // - Logged-in users: get_nonce() returns a user-specific nonce created in current session context
1002 // - Logged-out users: get_nonce() returns null, they'll fetch via /start_session endpoint
1003 // This prevents rest_cookie_invalid_nonce errors for logged-in users by ensuring the nonce
1004 // matches their authentication context from the start.
1005 'restNonce' => $crossSite ? null : $this->core->get_nonce(),
1006 'contextId' => get_the_ID(),
1007 'pluginUrl' => MWAI_URL,
1008 'restUrl' => untrailingslashit( get_rest_url() ),
1009 'stream' => $this->core->get_option( 'ai_streaming' ),
1010 'debugMode' => $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ),
1011 'eventLogs' => $this->core->get_option( 'event_logs' ),
1012 'speech_recognition' => $this->core->get_option( 'speech_recognition' ),
1013 'speech_synthesis' => $this->core->get_option( 'speech_synthesis' ),
1014 'typewriter' => $this->core->get_option( 'chatbot_typewriter' ),
1015 'crossSite' => $crossSite
1016 ];
1017 return $frontSystem;
1018 }
1019
1020 public function resolveBotInfo( &$atts ) {
1021 $chatbot = null;
1022 $botId = $atts['id'] ?? null;
1023 $customId = $atts['custom_id'] ?? null;
1024 $parentBotId = null;
1025
1026 if ( !$botId && !$customId ) {
1027 $botId = 'default';
1028 }
1029 if ( $botId ) {
1030 $chatbot = $this->core->get_chatbot( $botId );
1031 if ( !$chatbot ) {
1032 $botId = $botId ?: 'N/A';
1033 $safe_botId = esc_html( $botId );
1034 return [
1035 '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'.",
1036 ];
1037 }
1038 }
1039 $chatbot = $chatbot ?: $this->core->get_chatbot( 'default' );
1040
1041 if ( !empty( $customId ) ) {
1042 if ( $botId !== null ) {
1043 $parentBotId = $botId;
1044 $botId = null;
1045 }
1046 }
1047
1048 unset( $atts['id'] );
1049 return [
1050 'chatbot' => $chatbot,
1051 'botId' => $botId,
1052 'customId' => $customId,
1053 'parentBotId' => $parentBotId
1054 ];
1055 }
1056
1057 public function chat_shortcode( $atts ) {
1058 $atts = empty( $atts ) ? [] : $atts;
1059
1060 foreach ( $atts as $key => $value ) {
1061 $atts[ $key ] = urldecode( $value );
1062 }
1063
1064 // Let the user override the chatbot params
1065 $atts = apply_filters( 'mwai_chatbot_params', $atts );
1066
1067 // Resolve the bot info
1068 $resolvedBot = $this->resolveBotInfo( $atts );
1069 if ( isset( $resolvedBot['error'] ) ) {
1070 return $resolvedBot['error'];
1071 }
1072 $chatbot = $resolvedBot['chatbot'];
1073 $botId = $resolvedBot['botId'];
1074 $customId = $resolvedBot['customId'];
1075 $parentBotId = $resolvedBot['parentBotId'];
1076
1077 // Rename the keys of the atts into camelCase to match the internal params system.
1078 $atts = array_map( function ( $key, $value ) {
1079 $key = str_replace( '_', ' ', $key );
1080 $key = ucwords( $key );
1081 $key = str_replace( ' ', '', $key );
1082 $key = lcfirst( $key );
1083 return [ $key => $value ];
1084 }, array_keys( $atts ), $atts );
1085 $atts = array_merge( ...$atts );
1086
1087 if ( !empty( $parentBotId ) ) {
1088 $atts['parentBotId'] = $parentBotId;
1089 }
1090
1091 $frontParams = [];
1092 // Define text parameters that need sanitization (excluding those that support HTML)
1093 $textParams = ['aiName', 'userName', 'guestName', 'textSend', 'textClear', 'textInputPlaceholder',
1094 'startSentence', 'iconText', 'iconAlt', 'headerSubtitle', 'popupTitle', 'allowedMimeTypes'];
1095 // Parameters that support HTML content
1096 $htmlParams = ['textCompliance'];
1097 // Boolean parameters that need special handling
1098 $booleanParams = ['window', 'copyButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1099 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1100
1101 foreach ( MWAI_CHATBOT_FRONT_PARAMS as $param ) {
1102 // Let's go through the overriden or custom params first (the ones passed in the shortcode)
1103 if ( isset( $atts[$param] ) ) {
1104 if ( $param === 'localMemory' ) {
1105 $frontParams[$param] = $atts[$param] === 'true';
1106 }
1107 else if ( in_array( $param, $textParams ) ) {
1108 // Sanitize text parameters to prevent XSS
1109 $frontParams[$param] = sanitize_text_field( $atts[$param] );
1110 }
1111 else if ( in_array( $param, $htmlParams ) ) {
1112 // For HTML parameters, use wp_kses_post to allow safe HTML
1113 $frontParams[$param] = wp_kses_post( $atts[$param] );
1114 }
1115 else if ( in_array( $param, $booleanParams ) ) {
1116 // Convert to proper boolean
1117 // Handle various boolean representations from shortcode attributes
1118 $value = $atts[$param];
1119 if ( is_bool( $value ) ) {
1120 $frontParams[$param] = $value;
1121 }
1122 else if ( is_string( $value ) ) {
1123 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0' && $value !== 'no';
1124 }
1125 else {
1126 $frontParams[$param] = !empty( $value );
1127 }
1128 }
1129 else {
1130 $frontParams[$param] = $atts[$param];
1131 }
1132 }
1133 // If not, let's use the chatbot's default values
1134 else if ( isset( $chatbot[$param] ) ) {
1135 if ( in_array( $param, $booleanParams ) ) {
1136 // Convert to proper boolean for chatbot defaults too
1137 // Handle various boolean representations
1138 $value = $chatbot[$param];
1139
1140 if ( is_bool( $value ) ) {
1141 $frontParams[$param] = $value;
1142 }
1143 else if ( is_string( $value ) ) {
1144 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0';
1145 }
1146 else {
1147 $frontParams[$param] = !empty( $value );
1148 }
1149 }
1150 else {
1151 $frontParams[$param] = $chatbot[$param];
1152 }
1153 }
1154
1155 // Apply the placeholders
1156 if ( in_array( $param, ['startSentence', 'iconText'] ) ) {
1157 $frontParams[$param] = $this->core->do_placeholders( $frontParams[$param] );
1158 }
1159 }
1160
1161 // Ensure upload params are synced
1162 // fileUpload (checkbox) determines if uploads are enabled
1163 // maxUploads (number) determines how many files can be uploaded
1164 $fileUploadEnabled = !empty( $frontParams['fileUpload'] ) || !empty( $frontParams['imageUpload'] );
1165 $maxFiles = isset( $frontParams['maxUploads'] ) ? max( 1, (int) $frontParams['maxUploads'] ) : 1;
1166
1167 // Sync all params for backward compatibility
1168 $frontParams['fileUpload'] = $fileUploadEnabled;
1169 $frontParams['imageUpload'] = $fileUploadEnabled;
1170 $frontParams['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1171 $frontParams['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1172 $frontParams['maxUploads'] = $maxFiles;
1173
1174 // Server Params
1175 // NOTE: We don't need the server params for the chatbot if there are no overrides, it means
1176 // we are using the default or a specific chatbot.
1177 $isSiteWide = $this->siteWideChatId && $botId === $this->siteWideChatId;
1178
1179 // Parameters that are purely visual/UI and shouldn't trigger custom ID
1180 $visualOnlyParams = [
1181 // Bot selectors
1182 'id', 'custom_id',
1183 // System-added params
1184 'crossSite',
1185 // Visual/UI parameters that don't affect AI behavior
1186 'aiName', 'userName', 'guestName', // Display names
1187 'aiAvatar', 'userAvatar', 'guestAvatar', 'aiAvatarUrl', 'userAvatarUrl', 'guestAvatarUrl', // Avatars
1188 'textSend', 'textClear', 'textInputPlaceholder', 'textCompliance', // UI text labels
1189 'textInputMaxLength', // Input constraint (visual)
1190 'themeId', // Theme selection
1191 'window', 'icon', 'iconText', 'iconTextDelay', 'iconAlt', 'iconPosition', // Window/icon settings
1192 'centerOpen', 'width', 'openDelay', 'iconBubble', 'windowAnimation', 'fullscreen', // Window behavior
1193 'copyButton', 'headerSubtitle', 'popupTitle', // UI features
1194 'containerType', 'headerType', 'messagesType', 'inputType', 'footerType' // UI style variants
1195 ];
1196
1197 // Remove visual-only params from override detection
1198 $attsForOverrideCheck = array_diff_key( $atts, array_flip( $visualOnlyParams ) );
1199
1200 // Only these front params affect behavior and should trigger custom ID:
1201 // - mode: chat vs. prompt mode
1202 // - startSentence: initial AI message
1203 // - localMemory: affects data persistence
1204 // - imageUpload, fileUpload, multiUpload, fileSearch: affect capabilities
1205 $behavioralFrontParams = ['mode', 'startSentence', 'localMemory', 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1206
1207 $hasServerOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), MWAI_CHATBOT_SERVER_PARAMS ) ) > 0;
1208 $hasBehavioralFrontOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), $behavioralFrontParams ) ) > 0;
1209 $hasOverrides = !$isSiteWide && ( $hasServerOverrides || $hasBehavioralFrontOverrides );
1210
1211 $serverParams = [];
1212 if ( $hasOverrides ) {
1213 // Server parameters don't need sanitization as they're processed server-side
1214 // and not rendered in HTML. They may contain code, HTML, etc. for AI context.
1215 foreach ( MWAI_CHATBOT_SERVER_PARAMS as $param ) {
1216 if ( isset( $atts[$param] ) ) {
1217 $serverParams[$param] = $atts[$param];
1218 }
1219 else {
1220 // For custom chatbots, don't inherit embeddingsEnvId from the default chatbot
1221 if ( $param === 'embeddingsEnvId' && !empty( $customId ) ) {
1222 $serverParams[$param] = '';
1223 }
1224 else {
1225 $serverParams[$param] = $chatbot[$param] ?? null;
1226 }
1227 }
1228 }
1229 }
1230
1231 // Front Params
1232 $frontSystem = $this->build_front_params( $botId, $customId );
1233
1234 // Clean Params
1235 $frontParams = $this->clean_params( $frontParams );
1236 $frontSystem = $this->clean_params( $frontSystem );
1237 $serverParams = $this->clean_params( $serverParams );
1238
1239 // Server-side: Keep the System Params
1240 if ( $hasOverrides ) {
1241 if ( empty( $customId ) ) {
1242 $customId = md5( json_encode( $serverParams ) );
1243 $frontSystem['customId'] = $customId;
1244 }
1245 set_transient( 'mwai_custom_chatbot_' . $customId, $serverParams, 60 * 60 * 24 );
1246 }
1247
1248 // Retrieve the actions, shortcuts, and blocks we want to inject at the beginning
1249 $filterParams = [
1250 'step' => 'init',
1251 'botId' => $botId,
1252 'params' => array_merge( $frontParams, $frontSystem, $serverParams )
1253 ];
1254 $actions = apply_filters( 'mwai_chatbot_actions', [], $filterParams );
1255 $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams );
1256 $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams );
1257 $frontSystem['actions'] = $this->sanitize_actions( $actions );
1258 $frontSystem['blocks'] = $this->sanitize_blocks( $blocks );
1259 $shortcuts = $this->sanitize_shortcuts( $shortcuts );
1260 $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
1261 $frontSystem['shortcuts'] = $shortcuts;
1262
1263 // Client-side: Prepare JSON for Front Params and System Params
1264 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1265 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1266 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1267 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1268 //$jsonAttributes = htmlspecialchars(json_encode($atts), ENT_QUOTES, 'UTF-8');
1269
1270 $this->enqueue_scripts( $frontParams['themeId'] ?? null );
1271
1272 return "<div class='mwai-chatbot-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1273 }
1274
1275 public function chatbot_discussions( $atts ) {
1276 $atts = empty( $atts ) ? [] : $atts;
1277
1278 // Resolve the bot info
1279 $resolvedBot = $this->resolveBotInfo( $atts );
1280 if ( isset( $resolvedBot['error'] ) ) {
1281 return $resolvedBot['error'];
1282 }
1283 $chatbot = $resolvedBot['chatbot'];
1284 $botId = $resolvedBot['botId'];
1285 $customId = $resolvedBot['customId'];
1286
1287 // Rename the keys of the atts into camelCase to match the internal params system.
1288 $atts = array_map( function ( $key, $value ) {
1289 $key = str_replace( '_', ' ', $key );
1290 $key = ucwords( $key );
1291 $key = str_replace( ' ', '', $key );
1292 $key = lcfirst( $key );
1293 return [ $key => $value ];
1294 }, array_keys( $atts ), $atts );
1295 $atts = array_merge( ...$atts );
1296
1297 // Front Params
1298 $frontParams = [];
1299 // All discussion params are text params that need sanitization
1300 $textParams = ['textNewChat'];
1301
1302 foreach ( MWAI_DISCUSSIONS_FRONT_PARAMS as $param ) {
1303 if ( isset( $atts[$param] ) ) {
1304 // Sanitize text parameters
1305 $frontParams[$param] = in_array( $param, $textParams ) ? sanitize_text_field( $atts[$param] ) : $atts[$param];
1306 }
1307 else if ( isset( $chatbot[$param] ) ) {
1308 $frontParams[$param] = $chatbot[$param];
1309 }
1310 }
1311
1312 // Server Params
1313 $serverParams = [];
1314 foreach ( MWAI_DISCUSSIONS_SERVER_PARAMS as $param ) {
1315 if ( isset( $atts[$param] ) ) {
1316 $serverParams[$param] = $atts[$param];
1317 }
1318 }
1319
1320 // Front System
1321 $frontSystem = $this->build_front_params( $botId, $customId );
1322 // Get refresh interval from settings
1323 $refresh_interval = $this->core->get_option( 'chatbot_discussions_refresh_interval' );
1324 if ( $refresh_interval === 'Never' ) {
1325 $frontSystem['refreshInterval'] = 0;
1326 }
1327 elseif ( $refresh_interval === 'Manual' ) {
1328 $frontSystem['refreshInterval'] = -1;
1329 }
1330 elseif ( is_numeric( $refresh_interval ) ) {
1331 $frontSystem['refreshInterval'] = intval( $refresh_interval ) * 1000; // Convert to milliseconds
1332 }
1333 else {
1334 $frontSystem['refreshInterval'] = 5000; // Default to 5 seconds
1335 }
1336 $frontSystem['refreshInterval'] = apply_filters( 'mwai_discussions_refresh_interval', $frontSystem['refreshInterval'] );
1337
1338 // Get paging setting
1339 $paging_option = $this->core->get_option( 'chatbot_discussions_paging' );
1340 if ( $paging_option === 'None' ) {
1341 $frontSystem['paging'] = 0; // No pagination
1342 }
1343 else {
1344 $frontSystem['paging'] = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Default to 10
1345 }
1346
1347 // Get metadata settings
1348 $frontSystem['metadata'] = [
1349 'enabled' => $this->core->get_option( 'chatbot_discussions_metadata_enabled' ),
1350 'startDate' => $this->core->get_option( 'chatbot_discussions_metadata_start_date' ),
1351 'lastUpdate' => $this->core->get_option( 'chatbot_discussions_metadata_last_update' ),
1352 'messageCount' => $this->core->get_option( 'chatbot_discussions_metadata_message_count' )
1353 ];
1354
1355 // Clean Params
1356 $frontParams = $this->clean_params( $frontParams );
1357 $frontSystem = $this->clean_params( $frontSystem );
1358 $serverParams = $this->clean_params( $serverParams );
1359
1360 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1361 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1362 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1363 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1364
1365 return "<div class='mwai-discussions-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1366 }
1367
1368 public function clean_params( &$params ) {
1369 foreach ( $params as $param => $value ) {
1370 if ( $param === 'restNonce' ) {
1371 continue;
1372 }
1373 // Skip only if value is null or an array - but not if it's false or 0
1374 if ( is_null( $value ) || is_array( $value ) ) {
1375 continue;
1376 }
1377 // Handle empty strings
1378 if ( $value === '' ) {
1379 continue;
1380 }
1381 $lowerCaseValue = is_string( $value ) ? strtolower( $value ) : '';
1382 if ( $lowerCaseValue === 'true' || $lowerCaseValue === 'false' || is_bool( $value ) ) {
1383 $params[$param] = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
1384 }
1385 else if ( is_numeric( $value ) ) {
1386 $params[$param] = filter_var( $value, FILTER_VALIDATE_FLOAT );
1387 }
1388 }
1389 return $params;
1390 }
1391
1392 }
1393