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