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