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