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