PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 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 5 months ago chatbot.php 6 days ago discussions.php 6 days ago editor-assistant.php 6 days ago files.php 4 weeks ago forms-manager.php 5 months ago gdpr.php 6 months ago search.php 5 months ago security.php 1 year ago tasks-examples.php 8 months ago tasks.php 4 weeks ago wand.php 5 months ago workspace.php 3 weeks ago
chatbot.php
1593 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 // A paused turn waiting for a tool approval (Workspace WordPress Tools).
251 if ( !empty( $data['approval'] ) ) {
252 $final_res['approval'] = $data['approval'];
253 }
254 return $this->create_rest_response( $final_res, 200 );
255 }
256 catch ( Exception $e ) {
257 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
258 // Refusals (limits, security) carry a user-facing message; overLimit lets
259 // the frontend lock the input instead of accepting doomed messages.
260 $isRefusal = $e instanceof Meow_MWAI_RefusedException;
261 $overLimit = $isRefusal && $e->reason === 'limits';
262
263 // If we're in streaming mode, send the error through the stream
264 if ( $stream ) {
265 // Log the error
266 error_log( '[AI Engine Chatbot Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
267
268 // Send error event through stream. Internal errors stay generic for
269 // visitors; a refusal message is meant for them, so pass it through.
270 // Admins get the real error (they can act on it, e.g. in the Workspace).
271 $errorData = [
272 'type' => 'error',
273 'data' => ( $isRefusal || current_user_can( 'manage_options' ) ) ? $message
274 : Meow_MWAI_Core::get_public_error_message( $e ),
275 'overLimit' => $overLimit
276 ];
277 echo 'data: ' . json_encode( $errorData ) . "\n\n";
278 if ( ob_get_level() > 0 ) {
279 ob_end_flush();
280 }
281 flush();
282 die();
283 }
284
285 // For non-streaming, same rule as the streaming branch above: a refusal is
286 // written for the visitor and passes through, but an internal error (a
287 // provider failure, a function-call loop, etc.) must not leak its details
288 // to visitors. Mask it to a generic message for non-admins (the real one
289 // is logged); admins still see it so they can debug.
290 if ( !$isRefusal && !current_user_can( 'manage_options' ) ) {
291 error_log( '[AI Engine Chatbot Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
292 $message = Meow_MWAI_Core::get_public_error_message( $e );
293 }
294 return $this->create_rest_response( [
295 'success' => false,
296 'message' => $message,
297 'overLimit' => $overLimit
298 ], $overLimit ? 429 : 500 );
299 }
300 }
301
302 private function sanitize_items( $items, $supported_types, $type_name ) {
303 if ( empty( $items ) ) {
304 return $items;
305 }
306 $sanitized_items = [];
307 foreach ( $items as $item ) {
308 if ( isset( $supported_types[$item['type']] ) ) {
309 $is_valid = true;
310 foreach ( $supported_types[$item['type']] as $param ) {
311 if ( !isset( $item['data'][$param] ) ) {
312 $is_valid = false;
313 Meow_MWAI_Logging::warn( "The query was rejected - missing required parameter '{$param}' for {$type_name} type: {$item['type']}." );
314 break;
315 }
316 }
317 if ( $is_valid ) {
318 $sanitized_items[] = $item;
319 }
320 }
321 else {
322 Meow_MWAI_Logging::warn( "The query was rejected - unsupported {$type_name} type: {$item['type']}." );
323 }
324 }
325 return $sanitized_items;
326 }
327
328 public function sanitize_actions( $actions ) {
329 $supported_action_types = [
330 'function' => ['name', 'args'],
331 'javascript' => ['snippet'],
332 ];
333 return $this->sanitize_items( $actions, $supported_action_types, 'action' );
334 }
335
336 public function sanitize_blocks( $blocks ) {
337 $supported_block_types = [
338 'content' => ['html'],
339 ];
340 return $this->sanitize_items( $blocks, $supported_block_types, 'block' );
341 }
342
343 public function sanitize_shortcuts( $shortcuts ) {
344 $supported_shortcut_types = [
345 'message' => ['label', 'message'],
346 'action' => ['label', 'message', 'action'],
347 'callback' => ['label', 'onClick'],
348 ];
349 return $this->sanitize_items( $shortcuts, $supported_shortcut_types, 'shortcut' );
350 }
351
352 /**
353 * Get the encryption key derived from WordPress salts.
354 *
355 * @return string The 32-byte encryption key.
356 */
357 private function get_shortcut_encryption_key() {
358 return substr( hash( 'sha256', wp_salt( 'auth' ) . 'mwai_shortcuts' ), 0, 32 );
359 }
360
361 /**
362 * Encrypt shortcut data for safe transmission to the client.
363 *
364 * @param array $data The data to encrypt (message, botId).
365 * @return string|null The base64-encoded encrypted payload, or null on failure.
366 */
367 private function encrypt_shortcut_data( $data ) {
368 $key = $this->get_shortcut_encryption_key();
369 $iv = openssl_random_pseudo_bytes( 16 );
370 $json = json_encode( $data );
371 $encrypted = openssl_encrypt( $json, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
372 if ( $encrypted === false ) {
373 return null;
374 }
375 return base64_encode( $iv . $encrypted );
376 }
377
378 /**
379 * Decrypt shortcut data received from the client.
380 *
381 * @param string $payload The base64-encoded encrypted payload.
382 * @return array|null The decrypted data, or null on failure.
383 */
384 private function decrypt_shortcut_data( $payload ) {
385 $key = $this->get_shortcut_encryption_key();
386 $decoded = base64_decode( $payload, true );
387 if ( $decoded === false || strlen( $decoded ) < 17 ) {
388 return null;
389 }
390 $iv = substr( $decoded, 0, 16 );
391 $encrypted = substr( $decoded, 16 );
392 $decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
393 if ( $decrypted === false ) {
394 return null;
395 }
396 return json_decode( $decrypted, true );
397 }
398
399 /**
400 * Prepare shortcuts for client by replacing messages with encrypted shortcutIds.
401 * The messages are encrypted and can only be decrypted server-side.
402 * This keeps the prompt content private and not exposed in the browser.
403 *
404 * @param array $shortcuts The shortcuts to prepare.
405 * @param string $botId The bot ID for validation.
406 * @return array The prepared shortcuts with encrypted shortcutIds instead of messages.
407 */
408 public function prepare_shortcuts_for_client( $shortcuts, $botId ) {
409 if ( empty( $shortcuts ) ) {
410 return $shortcuts;
411 }
412
413 $prepared = [];
414 foreach ( $shortcuts as $shortcut ) {
415 $type = $shortcut['type'] ?? '';
416 $data = $shortcut['data'] ?? [];
417
418 // Only process shortcuts that have a message (not callbacks)
419 if ( isset( $data['message'] ) && !empty( $data['message'] ) ) {
420 // Encrypt the message and botId
421 $shortcutId = $this->encrypt_shortcut_data( [
422 'message' => $data['message'],
423 'botId' => $botId,
424 ] );
425
426 if ( $shortcutId ) {
427 // Replace message with encrypted shortcutId
428 unset( $data['message'] );
429 $data['shortcutId'] = $shortcutId;
430 }
431 }
432
433 $prepared[] = [
434 'type' => $type,
435 'data' => $data,
436 ];
437 }
438
439 return $prepared;
440 }
441
442 /**
443 * Decrypt and retrieve a shortcut message from its encrypted ID.
444 *
445 * @param string $shortcutId The encrypted shortcut ID.
446 * @param string $botId The bot ID for validation.
447 * @return string|null The message, or null if decryption fails or botId mismatches.
448 */
449 public function get_shortcut_message( $shortcutId, $botId ) {
450 if ( empty( $shortcutId ) ) {
451 return null;
452 }
453
454 $shortcut_data = $this->decrypt_shortcut_data( $shortcutId );
455
456 if ( !$shortcut_data || !isset( $shortcut_data['message'] ) ) {
457 Meow_MWAI_Logging::warn( "Shortcut decryption failed for botId: {$botId}" );
458 return null;
459 }
460
461 // Validate botId matches (security check)
462 if ( isset( $shortcut_data['botId'] ) && $shortcut_data['botId'] !== $botId ) {
463 Meow_MWAI_Logging::warn( "Shortcut botId mismatch: expected {$shortcut_data['botId']}, got {$botId}" );
464 return null;
465 }
466
467 return $shortcut_data['message'];
468 }
469
470 #region Messages Integrity Check
471
472 public function messages_integrity_diff( $messages1, $messages2 ) {
473 // Ensure both parameters are arrays
474 if ( !is_array( $messages1 ) ) {
475 $messages1 = [];
476 }
477 if ( !is_array( $messages2 ) ) {
478 $messages2 = [];
479 }
480
481 // Collect messages with role not 'user' from messages1
482 $messagesList1 = [];
483 foreach ( $messages1 as $msg ) {
484 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
485 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
486 if ( $role && $role != 'user' ) {
487 $messageData = [ 'role' => $role, 'content' => $content ];
488 $messagesList1[] = $messageData;
489 }
490 }
491
492 // Collect messages with role not 'user' from messages2
493 $messagesList2 = [];
494 foreach ( $messages2 as $msg ) {
495 $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null );
496 $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null );
497 if ( $role && $role != 'user' ) {
498 $messageData = [ 'role' => $role, 'content' => $content ];
499 $messagesList2[] = $messageData;
500 }
501 }
502
503 // Count occurrences of each message in messagesList1
504 $counts1 = [];
505 foreach ( $messagesList1 as $msg ) {
506 $key = serialize( $msg );
507 if ( isset( $counts1[ $key ] ) ) {
508 $counts1[ $key ]++;
509 }
510 else {
511 $counts1[ $key ] = 1;
512 }
513 }
514
515 // Count occurrences of each message in messagesList2
516 $counts2 = [];
517 foreach ( $messagesList2 as $msg ) {
518 $key = serialize( $msg );
519 if ( isset( $counts2[ $key ] ) ) {
520 $counts2[ $key ]++;
521 }
522 else {
523 $counts2[ $key ] = 1;
524 }
525 }
526
527 // Compare counts to find unmatched messages
528 $all_keys = array_unique( array_merge( array_keys( $counts1 ), array_keys( $counts2 ) ) );
529
530 $diffs = [];
531 foreach ( $all_keys as $key ) {
532 $count1 = isset( $counts1[ $key ] ) ? $counts1[ $key ] : 0;
533 $count2 = isset( $counts2[ $key ] ) ? $counts2[ $key ] : 0;
534 if ( $count1 != $count2 ) {
535 $message = unserialize( $key );
536 $diffs[] = [
537 'message' => $message,
538 'count_in_messages1' => $count1,
539 'count_in_messages2' => $count2
540 ];
541 }
542 }
543
544 return $diffs;
545 }
546
547 private function calculate_messages_checksum( $messages ) {
548 $messages_to_hash = [];
549 foreach ( $messages as $msg ) {
550 $role = is_array( $msg ) ? ( $msg['role'] ?? '' ) : ( is_object( $msg ) ? ( $msg->role ?? '' ) : '' );
551 $content = is_array( $msg ) ? ( $msg['content'] ?? '' ) : ( is_object( $msg ) ? ( $msg->content ?? '' ) : '' );
552 if ( in_array( $role, ['assistant', 'system'] ) ) {
553 $messages_to_hash[] = [ 'role' => $role, 'content' => $content ];
554 }
555 }
556 return md5( json_encode( $messages_to_hash ) );
557 }
558
559 #endregion
560
561 public function chat_submit( $botId, $newMessage, $newFileId = null, $params = [], $stream = false, $newFileIds = [] ) {
562 $query = null; // Initialize query variable to avoid undefined variable errors
563 try {
564 $chatbot = null;
565 $customId = $params['customId'] ?? null;
566
567 // Server params (model, envId, instructions, apiKey, tools, mcpServers...)
568 // are configured server-side and only reach a request through the resolved
569 // chatbot: a registered botId, or the customId transient that a shortcode
570 // override stores. They must never be trusted from the request body, or a
571 // hand-crafted call to this public endpoint could force an expensive model,
572 // swap the environment (and its API key), or replace the system prompt on
573 // someone else's site. Callers who can configure chatbots anyway (the
574 // Workspace, admins) are exempt so their per-conversation overrides keep
575 // working. See MWAI_CHATBOT_SERVER_PARAMS and the shortcode override path.
576 //
577 // This runs BEFORE the chatbot is resolved, and that ordering is the point. The
578 // mwai_internal_chatbot filter below reads $params to build its chatbot, so when
579 // the strip ran after it the Editor Assistant had already baked a caller-supplied
580 // envId into the resolved chatbot and stripping $params afterwards was too late:
581 // a guest could still pick which environment (and API key) paid for the query.
582 // Only $customId is read before this point, and it is not a server param.
583 $allowClientServerParams = apply_filters(
584 'mwai_chatbot_allow_client_server_params',
585 $this->core->can_access_settings(),
586 $botId,
587 $params
588 );
589 if ( !$allowClientServerParams && is_array( $params ) ) {
590 foreach ( MWAI_CHATBOT_SERVER_PARAMS as $serverParam ) {
591 unset( $params[$serverParam] );
592 }
593 // The history in the body is display context, not a control channel.
594 // build_messages() appends these verbatim right after the real system
595 // prompt, so a 'system' (or 'developer'/'tool') turn smuggled in here
596 // would re-open the prompt-override hole from a second door. Keep only
597 // the user/assistant turns a real conversation is made of.
598 if ( !empty( $params['messages'] ) && is_array( $params['messages'] ) ) {
599 $params['messages'] = array_values( array_filter( $params['messages'], function ( $m ) {
600 $role = is_array( $m ) ? ( $m['role'] ?? '' ) : ( is_object( $m ) ? ( $m->role ?? '' ) : '' );
601 return in_array( $role, [ 'user', 'assistant' ], true );
602 } ) );
603 }
604 }
605
606 // Custom Chatbot
607 if ( $customId ) {
608 $chatbot = get_transient( 'mwai_custom_chatbot_' . $customId );
609 }
610 // Registered Chatbot
611 if ( !$chatbot && $botId ) {
612 $chatbot = $this->core->get_chatbot( $botId );
613 }
614 // Internal Chatbots (reserved mwai_ prefix)
615 if ( !$chatbot && $botId && strpos( $botId, 'mwai_' ) === 0 ) {
616 $chatbot = apply_filters( 'mwai_internal_chatbot', null, $botId, $params );
617 }
618 // Fall back to default chatbot if no chatbot found yet
619 if ( !$chatbot ) {
620 $chatbot = $this->core->get_chatbot( 'default' );
621 }
622
623 if ( !$chatbot ) {
624 Meow_MWAI_Logging::warn( 'The query was rejected - no chatbot was found.' );
625 throw new Exception( 'Sorry, your query has been rejected.' );
626 }
627
628 $textInputMaxLength = $chatbot['textInputMaxLength'] ?? null;
629 if ( $textInputMaxLength && $this->core->safe_strlen( $newMessage ) > (int) $textInputMaxLength ) {
630 Meow_MWAI_Logging::warn( 'The query was rejected - message was too long.' );
631 throw new Exception( 'Sorry, your query has been rejected.' );
632 }
633
634 // We need to check the integrity of the messages sent by the client.
635 // This is important to ensure that the messages are not tampered with.
636
637 // Messages Integrity Check with Checksums
638 $chatId = $params['chatId'] ?? 'default';
639 $checksum_key = 'mwai_chatbot_checksum_' . $chatId;
640 $stored_checksum = get_transient( $checksum_key );
641 $client_messages = $params['messages'] ?? [];
642 $client_checksum = $this->calculate_messages_checksum( $client_messages );
643 if ( $stored_checksum && $stored_checksum !== $client_checksum ) {
644 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.' );
645 }
646
647 // Messages Integrity Check with Discussions
648 if ( $this->core->get_option( 'chatbot_discussions' ) && $this->core->discussions && isset( $params['chatId'] ) ) {
649 $discussion = $this->core->discussions->get_discussion( $botId ? $botId : $customId, $params['chatId'] );
650 if ( $discussion ) {
651 $messages = $discussion['messages'];
652 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
653 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
654 if ( count( $diffs ) > 0 ) {
655 Meow_MWAI_Logging::warn( "Integrity Check: It seems the messages in the discussion #{$discussion['id']} do not match the ones sent by the client." );
656 }
657
658 // Maintain conversation state for the Responses API by restoring the
659 // stored response id when the client did not send one. Two things must
660 // both hold, and both were wrong before:
661 // 1. The keys. Discussions store previousResponseId / previousResponseDate
662 // (see discussions.php), not responseId / responseDate, so this
663 // restore silently never ran.
664 // 2. Ownership. A response id resumes the whole conversation server-side
665 // at the provider, so restoring it for a chatId that belongs to
666 // someone else would replay their private conversation to whoever
667 // supplies the chatId. get_discussion() is intentionally not
668 // owner-scoped, so gate the restore on ownership here: the same
669 // logged-in user, or the same guest session that created it. The
670 // official UI already restores previousResponseId client-side, so
671 // this stays a safe server-side fallback, not the primary path.
672 if ( empty( $params['previousResponseId'] ) && !empty( $discussion['extra'] ) ) {
673 $extra = json_decode( $discussion['extra'], true );
674 $storedResponseId = $extra['previousResponseId'] ?? null;
675 if ( !empty( $storedResponseId ) ) {
676 $currentUserId = get_current_user_id();
677 $ownsDiscussion = false;
678 if ( $currentUserId && !empty( $discussion['userId'] )
679 && (int) $discussion['userId'] === (int) $currentUserId ) {
680 $ownsDiscussion = true;
681 }
682 else if ( !$currentUserId && !empty( $params['session'] ) && !empty( $extra['session'] )
683 && hash_equals( (string) $extra['session'], (string) $params['session'] ) ) {
684 $ownsDiscussion = true;
685 }
686 // Response IDs expire after 30 days per OpenAI's policy.
687 $responseDate = !empty( $extra['previousResponseDate'] )
688 ? strtotime( $extra['previousResponseDate'] ) : 0;
689 $thirtyDaysAgo = time() - ( 30 * 24 * 60 * 60 );
690 if ( $ownsDiscussion && $responseDate > $thirtyDaysAgo ) {
691 $params['previousResponseId'] = $storedResponseId;
692 }
693 }
694 }
695 }
696 else {
697 // No discussion yet? We still need to check the startSentence.
698 $startSentence = isset( $chatbot['startSentence'] ) ? $chatbot['startSentence'] : null;
699 $messages = [];
700 if ( !empty( $startSentence ) ) {
701 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
702 }
703 $clientMessages = isset( $params['messages'] ) ? $params['messages'] : [];
704 $diffs = $this->messages_integrity_diff( $messages, $clientMessages );
705 if ( count( $diffs ) > 0 ) {
706 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 ) );
707 }
708 }
709 }
710
711 // Create QueryText
712 $context = null;
713 $streamCallback = null;
714 $mode = $chatbot['mode'] ?? 'chat';
715
716 if ( $mode === 'images' ) {
717 // Check for uploaded files
718 $fileForImage = null;
719 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
720 $fileForImage = $newFileIds[0];
721 }
722 elseif ( !empty( $newFileId ) ) {
723 $fileForImage = $newFileId;
724 }
725
726 // If there's an uploaded file, use EditImage query instead
727 if ( !empty( $fileForImage ) ) {
728 $query = new Meow_MWAI_Query_EditImage( $newMessage );
729
730 // Handle the uploaded image
731 $url = $this->core->files->get_url( $fileForImage );
732 $mimeType = $this->core->files->get_mime_type( $fileForImage );
733 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
734
735 if ( $isIMG ) {
736 $query->add_file( Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType ) );
737 $fileId = $this->core->files->get_id_from_refId( $fileForImage );
738 $this->core->files->update_purpose( $fileId, 'analysis' );
739 }
740 }
741 else {
742 $query = new Meow_MWAI_Query_Image( $newMessage );
743 }
744
745 // Handle Params
746 $newParams = [];
747 foreach ( $chatbot as $key => $value ) {
748 $newParams[$key] = $value;
749 }
750 if ( is_array( $params ) ) {
751 foreach ( $params as $key => $value ) {
752 $newParams[$key] = $value;
753 }
754 }
755
756 // Map 'environment' field to 'envId' for compatibility
757 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
758 $newParams['envId'] = $newParams['environment'];
759 }
760
761 $params = apply_filters( 'mwai_chatbot_params', $newParams );
762 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
763
764 // Debug log for embeddings
765 if ( !empty( $params['embeddingsEnvId'] ) ) {
766 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
767 }
768 else {
769 // Log all params to debug
770 $paramKeys = array_keys( $params );
771 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
772 }
773
774 $query->inject_params( $params );
775 }
776 else {
777 $query = $mode === 'assistant' ? new Meow_MWAI_Query_Assistant( $newMessage ) :
778 new Meow_MWAI_Query_Text( $newMessage, 4096 );
779
780 // Handle Params
781 $newParams = [];
782 foreach ( $chatbot as $key => $value ) {
783 $newParams[$key] = $value;
784 }
785 if ( is_array( $params ) ) {
786 foreach ( $params as $key => $value ) {
787 $newParams[$key] = $value;
788 }
789 }
790
791 // Map 'environment' field to 'envId' for compatibility
792 if ( isset( $newParams['environment'] ) && !isset( $newParams['envId'] ) ) {
793 $newParams['envId'] = $newParams['environment'];
794 }
795
796 $params = apply_filters( 'mwai_chatbot_params', $newParams );
797 $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope'];
798
799 // Debug log for embeddings
800 if ( !empty( $params['embeddingsEnvId'] ) ) {
801 Meow_MWAI_Logging::log( 'Chatbot: Setting embeddingsEnvId on query: ' . $params['embeddingsEnvId'] );
802 }
803 else {
804 // Log all params to debug
805 $paramKeys = array_keys( $params );
806 Meow_MWAI_Logging::log( 'Chatbot: No embeddingsEnvId found. Available params: ' . implode( ', ', $paramKeys ) );
807 }
808
809 // In Prompt mode, clear out features that are not supported before injecting params
810 if ( $mode === 'prompt' ) {
811 // Clear embeddings/context settings
812 unset( $params['embeddingsEnvId'] );
813 unset( $params['embeddingsIndex'] );
814 unset( $params['embeddingsNamespace'] );
815 unset( $params['contentAware'] );
816 unset( $params['context'] );
817
818 // Clear function calling and MCP servers
819 unset( $params['functions'] );
820 unset( $params['mcpServers'] );
821
822 // Clear tools
823 unset( $params['tools'] );
824
825 // Clear temperature, reasoning, verbosity as they're configured in the prompt
826 unset( $params['temperature'] );
827 unset( $params['reasoningEffort'] );
828 unset( $params['verbosity'] );
829 unset( $params['maxTokens'] );
830 }
831
832 $query->inject_params( $params );
833
834 // Handle Prompt mode specifics
835 if ( $mode === 'prompt' && !empty( $params['promptId'] ) ) {
836 $promptData = [ 'id' => $params['promptId'] ];
837 $query->setExtraParam( 'prompt', $promptData );
838 }
839
840 $storeId = null;
841 if ( $mode === 'assistant' ) {
842 $chatId = $params['chatId'] ?? null;
843 if ( !empty( $chatId ) && $this->core->discussions ) {
844 $discussion = $this->core->discussions->get_discussion( $query->botId, $chatId );
845 if ( isset( $discussion['storeId'] ) ) {
846 $storeId = $discussion['storeId'];
847 $query->setStoreId( $storeId );
848 }
849 }
850 }
851
852 // Support for Multiple Uploaded Files
853 $filesToProcess = [];
854 if ( !empty( $newFileIds ) && is_array( $newFileIds ) ) {
855 $filesToProcess = $newFileIds;
856 }
857 elseif ( !empty( $newFileId ) ) {
858 $filesToProcess[] = $newFileId;
859 }
860
861 // Support for Uploaded Image/Files
862 if ( !empty( $filesToProcess ) ) {
863 // Process all files for multi-upload support
864 foreach ( $filesToProcess as $fileToProcess ) {
865 // Get extension and mime type
866 $isImage = $this->core->files->is_image( $fileToProcess );
867
868 if ( $mode === 'assistant' && !$isImage ) {
869 // DEPRECATED: Assistants API and File Search are deprecated
870 // After August 26, 2026, this entire block should be removed
871 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.' );
872
873 $url = $this->core->files->get_path( $fileToProcess );
874 $data = $this->core->files->get_data( $fileToProcess );
875 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId );
876 $filename = basename( $url );
877
878 // Upload the file
879 $file = $openai->upload_file( $filename, $data, 'assistants' );
880
881 // Create a store
882 if ( empty( $storeId ) ) {
883 $chatbotName = 'mwai_' . strtolower( !empty( $chatbot['name'] ) ? $chatbot['name'] : 'default' );
884 if ( !empty( $query->chatId ) ) {
885 $chatbotName .= '_' . $query->chatId;
886 }
887 $metadata = [];
888 if ( !empty( $chatbot['assistantId'] ) ) {
889 $metadata['assistantId'] = $chatbot['assistantId'];
890 }
891 if ( !empty( $query->chatId ) ) {
892 $metadata['chatId'] = $query->chatId;
893 }
894 $expiry = $this->core->get_option( 'image_expires' );
895 $storeId = $openai->create_vector_store( $chatbotName, $expiry, $metadata );
896 $query->setStoreId( $storeId );
897 }
898
899 // Add the file to the store - wait a moment for store to be ready
900 sleep( 1 );
901 $storeFileId = $openai->add_vector_store_file( $storeId, $file['id'] );
902
903 if ( empty( $storeFileId ) ) {
904 throw new Exception( 'Failed to add file to vector store.' );
905 }
906
907 // Update the local file with the OpenAI RefId, StoreId and StoreFileId
908 $openAiRefId = $file['id'];
909 $internalFileId = $this->core->files->get_id_from_refId( $fileToProcess );
910 $this->core->files->update_refId( $internalFileId, $openAiRefId );
911 $this->core->files->update_envId( $internalFileId, $query->envId );
912 $this->core->files->update_purpose( $internalFileId, 'analysis' );
913 $this->core->files->add_metadata( $internalFileId, 'assistant_storeId', $storeId );
914 $this->core->files->add_metadata( $internalFileId, 'assistant_storeFileId', $storeFileId );
915 $fileToProcess = $openAiRefId;
916 $scope = $params['fileSearch'];
917 if ( $scope === 'discussion' || $scope === 'user' || $scope === 'assistant' ) {
918 $id = $this->core->files->get_id_from_refId( $fileToProcess );
919 $this->core->files->add_metadata( $id, 'assistant_scope', $scope );
920 }
921 }
922 else {
923 // Keep track of the internal file ID (before any OpenAI processing)
924 // Important: $fileToProcess is our internal database refId, not OpenAI's file_id
925 $internalRefId = $fileToProcess;
926 $url = $this->core->files->get_url( $internalRefId );
927 $mimeType = $this->core->files->get_mime_type( $internalRefId );
928 $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] );
929
930 // Create DroppedFile object - provider-agnostic approach
931 // Images use URL (can be sent as base64 or URL in messages)
932 // PDFs use refId (engines will upload to their Files API as needed)
933 if ( $isIMG ) {
934 $droppedFile = Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mimeType );
935 }
936 else {
937 // For PDFs and documents, use refId so engines can access file data directly
938 $droppedFile = Meow_MWAI_Query_DroppedFile::from_refId( $internalRefId, 'analysis', $mimeType );
939 }
940
941 // IMPORTANT: Always use add_file() to add to attachedFiles array
942 // This is the unified approach for both single and multi-file uploads
943 // Engines will check attachedFiles array first, then fall back to attachedFile (legacy)
944 $query->add_file( $droppedFile );
945
946 // Update metadata using the internal refId (not OpenAI file ID)
947 $fileId = $this->core->files->get_id_from_refId( $internalRefId );
948 $this->core->files->update_envId( $fileId, $query->envId );
949 $this->core->files->update_purpose( $fileId, 'analysis' );
950 $this->core->files->add_metadata( $fileId, 'query_envId', $query->envId );
951 $this->core->files->add_metadata( $fileId, 'query_session', $query->session );
952 }
953 }
954 }
955
956 // Takeover
957 $takeoverAnswer = apply_filters( 'mwai_chatbot_takeover', null, $query, $params );
958 if ( !empty( $takeoverAnswer ) ) {
959 $reply = new Meow_MWAI_Reply( $query );
960 $reply->result = $takeoverAnswer;
961 $rawText = apply_filters( 'mwai_chatbot_reply', $takeoverAnswer, $reply, $params, [] );
962 return [
963 'reply' => $rawText,
964 'chatId' => $this->core->fix_chat_id( $query, $params ),
965 'images' => null,
966 'actions' => [],
967 'usage' => null
968 ];
969 }
970
971 // Moderation
972 $moderationEnabled = $this->core->get_option( 'module_moderation' ) &&
973 $this->core->get_option( 'shortcode_chat_moderation' );
974 if ( $moderationEnabled ) {
975 global $mwai;
976 $isFlagged = $mwai->moderationCheck( $query->get_message() );
977 if ( $isFlagged ) {
978 throw new Exception( 'Sorry, your message has been rejected by moderation.' );
979 }
980 }
981
982 // Setup streaming if enabled (before embeddings to capture those events)
983 $streamCallback = null;
984 $debugEvents = [];
985
986 if ( $stream ) {
987 $streamCallback = function ( $reply ) use ( $query ) {
988 // Support both legacy string data and new Event objects
989 if ( is_string( $reply ) ) {
990 $this->core->stream_push( [ 'type' => 'live', 'data' => $reply ], $query );
991 }
992 else {
993 $this->core->stream_push( $reply, $query );
994 }
995 };
996 if ( headers_sent( $filename, $linenum ) ) {
997 throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." );
998 }
999 header( 'Cache-Control: no-cache' );
1000 header( 'Content-Type: text/event-stream' );
1001 // This is useful to disable buffering in nginx through headers.
1002 header( 'X-Accel-Buffering: no' );
1003 ob_implicit_flush( true );
1004 if ( ob_get_level() > 0 ) {
1005 ob_end_flush();
1006 }
1007 // Usage and credits are committed (via the mwai_ai_reply filter) only
1008 // after the model stream finishes. Without this, a client that
1009 // disconnects mid-stream kills the PHP script on the next flush,
1010 // before recording anything: the provider tokens are spent but the
1011 // query is never counted and the limit never decremented, so aborting
1012 // repeatedly evades usage limits. Finish the request even if the
1013 // client left so the accounting stays honest.
1014 ignore_user_abort( true );
1015 }
1016 else if ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ) {
1017 // For non-streaming debug mode, collect events
1018 $streamCallback = function ( $event ) use ( &$debugEvents ) {
1019 if ( is_object( $event ) && method_exists( $event, 'toArray' ) ) {
1020 $debugEvents[] = $event->toArray();
1021 }
1022 };
1023 }
1024
1025 // Awareness & Embeddings
1026 $context = $this->core->retrieve_context( $params, $query, $streamCallback );
1027 if ( !empty( $context ) ) {
1028 $query->set_context( $context['content'] );
1029 }
1030
1031 // Function Aware
1032 $query = apply_filters( 'mwai_chatbot_query', $query, $params );
1033 }
1034
1035 // Process Query
1036
1037 $reply = $this->core->run_query( $query, $streamCallback, true );
1038 $rawText = $reply->result;
1039 $extra = [];
1040 if ( $context ) {
1041 $extra = [ 'embeddings' => isset( $context['embeddings'] ) ? $context['embeddings'] : null ];
1042 }
1043 // Tools executed during this turn (functions, MCP, etc), so the Discussions
1044 // admin screen can show what ran and with which parameters.
1045 if ( !empty( $reply->toolCalls ) ) {
1046 $extra['toolCalls'] = $reply->toolCalls;
1047 }
1048 // Store response ID for Responses API stateful conversations
1049 // CRITICAL: Must store even when function calls are present
1050 // This enables the feedback query to use previous_response_id
1051 if ( !empty( $reply->id ) ) {
1052 $extra['responseId'] = $reply->id;
1053 $extra['responseDate'] = gmdate( 'Y-m-d H:i:s' ); // Track age for 30-day expiry
1054 }
1055 $rawText = apply_filters( 'mwai_chatbot_reply', $rawText, $reply, $params, $extra );
1056
1057 // Integrity Check: We need to store the checksum of the messages sent by the client.
1058 $stored_messages = $client_messages;
1059 $stored_messages[] = [ 'role' => 'user', 'content' => $newMessage ];
1060 $stored_messages[] = [ 'role' => 'assistant', 'content' => $rawText ];
1061 $stored_checksum = $this->calculate_messages_checksum( $stored_messages );
1062 set_transient( $checksum_key, $stored_checksum, 60 * 60 * 24 * 30 );
1063
1064 // Actions
1065 $actions = [];
1066 if ( $reply->needClientActions ) {
1067 foreach ( $reply->needClientActions as $action ) {
1068 $actions[] = [
1069 'type' => 'function',
1070 'data' => [
1071 'name' => $action['function']->name,
1072 'args' => $action['arguments']
1073 ]
1074 ];
1075 }
1076 }
1077
1078 $restRes = [
1079 'reply' => $rawText,
1080 'chatId' => $this->core->fix_chat_id( $query, $params ),
1081 'images' => $reply->get_type() === 'images' ? $reply->results : null,
1082 'actions' => $actions,
1083 'usage' => $reply->usage
1084 ];
1085
1086 // Add debug events if collected
1087 if ( !empty( $debugEvents ) ) {
1088 $restRes['debugEvents'] = $debugEvents;
1089 }
1090
1091 // Add response ID if available (for Responses API)
1092 if ( !empty( $reply->id ) ) {
1093 $restRes['responseId'] = $reply->id;
1094 }
1095
1096 // Process Reply
1097 if ( $stream ) {
1098 $final_res = $this->build_final_res(
1099 $botId,
1100 $newMessage,
1101 $newFileId,
1102 $params,
1103 $restRes['reply'],
1104 $restRes['images'],
1105 $restRes['actions'],
1106 $restRes['usage'],
1107 $restRes['responseId'] ?? null
1108 );
1109 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query );
1110 die();
1111 }
1112 else {
1113 return $restRes;
1114 }
1115
1116 }
1117 catch ( Meow_MWAI_ApprovalRequiredException $e ) {
1118 // Not an error: the AI wants to run a tool that needs the user's
1119 // approval (Workspace WordPress Tools). Pause the turn, hand the pending
1120 // call to the UI, and store nothing; the turn is re-run once the user
1121 // decides (with their decision as a request param).
1122 $approval = [ 'tool' => $e->tool, 'args' => $e->args ];
1123 if ( $stream ) {
1124 $this->core->stream_push( [
1125 'type' => 'live',
1126 'subtype' => 'approval_request',
1127 'data' => 'Waiting for your approval to run ' . $e->tool . '...',
1128 'metadata' => $approval,
1129 ], $query );
1130 $final_res = $this->build_final_res( $botId, $newMessage, $newFileId, $params, '', null, [], [] );
1131 $final_res['approval'] = $approval;
1132 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query );
1133 die();
1134 }
1135 return [ 'reply' => '', 'images' => null, 'actions' => [], 'usage' => [], 'approval' => $approval ];
1136 }
1137 catch ( Exception $e ) {
1138 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1139 if ( $stream ) {
1140 // In streaming mode this catch runs before rest_chat's, so the refusal
1141 // handling has to happen here too: overLimit lets the frontend lock the
1142 // input, and internal errors stay generic for visitors (admins get the
1143 // real one, they can act on it).
1144 $isRefusal = $e instanceof Meow_MWAI_RefusedException;
1145 $overLimit = $isRefusal && $e->reason === 'limits';
1146 if ( !$isRefusal && !current_user_can( 'manage_options' ) ) {
1147 error_log( '[AI Engine Chatbot Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1148 $message = Meow_MWAI_Core::get_public_error_message( $e );
1149 }
1150 $this->core->stream_push( [
1151 'type' => 'error',
1152 'data' => $message,
1153 'overLimit' => $overLimit
1154 ], $query );
1155 die();
1156 }
1157 else {
1158 throw $e;
1159 }
1160 }
1161 }
1162
1163 public function inject_chat() {
1164 $params = $this->core->get_chatbot( $this->siteWideChatId );
1165 $clean_params = [];
1166 if ( !empty( $params ) ) {
1167 $clean_params['window'] = true;
1168 $clean_params['id'] = $this->siteWideChatId;
1169 echo $this->chat_shortcode( $clean_params );
1170 }
1171 return null;
1172 }
1173
1174 public function build_front_params( $botId, $customId, $crossSite = false ) {
1175 $frontSystem = [
1176 'botId' => ( $customId && $customId !== '' ) ? null : sanitize_text_field( $botId ),
1177 'customId' => ( $customId && $customId !== '' ) ? sanitize_text_field( $customId ) : null,
1178 'userData' => $this->core->get_user_data(),
1179 // For logged-out users we deliberately do NOT embed a sessionId at HTML
1180 // render time. Page caches (WP Rocket, Cloudflare, Varnish, etc.) and
1181 // multi-backend setups would otherwise freeze one sessionId into the
1182 // cached markup and serve it to every visitor — collapsing per-visitor
1183 // rate limits, file ownership, and stats. The frontend fetches a fresh
1184 // sessionId via /start_session on first interaction, and the server
1185 // always derives session from the mwai_session_id cookie anyway
1186 // (Query_Base::__construct + inject_params ignore empty client values).
1187 'sessionId' => is_user_logged_in() ? $this->core->get_session_id() : null,
1188 // IMPORTANT: REST nonce handling differs by user state:
1189 // - Logged-in users: get_nonce() returns a user-specific nonce created in current session context
1190 // - Logged-out users: get_nonce() returns null, they'll fetch via /start_session endpoint
1191 // This prevents rest_cookie_invalid_nonce errors for logged-in users by ensuring the nonce
1192 // matches their authentication context from the start.
1193 'restNonce' => $crossSite ? null : $this->core->get_nonce(),
1194 'contextId' => is_singular() ? get_the_ID() : null,
1195 'pluginUrl' => untrailingslashit( MWAI_URL ),
1196 'restUrl' => untrailingslashit( get_rest_url() ),
1197 'stream' => $this->core->get_option( 'ai_streaming' ),
1198 'debugMode' => $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ),
1199 'eventLogs' => $this->core->get_option( 'event_logs' ),
1200 'speech_recognition' => $this->core->get_option( 'speech_recognition' ),
1201 'speech_synthesis' => $this->core->get_option( 'speech_synthesis' ),
1202 'typewriter' => $this->core->get_option( 'chatbot_typewriter' ),
1203 'crossSite' => $crossSite
1204 ];
1205 return $frontSystem;
1206 }
1207
1208 public function resolveBotInfo( &$atts ) {
1209 $chatbot = null;
1210 $botId = $atts['id'] ?? null;
1211 $customId = $atts['custom_id'] ?? null;
1212 $parentBotId = null;
1213
1214 if ( !$botId && !$customId ) {
1215 $botId = 'default';
1216 }
1217 if ( $botId ) {
1218 $chatbot = $this->core->get_chatbot( $botId );
1219 if ( !$chatbot ) {
1220 $botId = $botId ?: 'N/A';
1221 $safe_botId = esc_html( $botId );
1222 return [
1223 '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'.",
1224 ];
1225 }
1226 }
1227 $chatbot = $chatbot ?: $this->core->get_chatbot( 'default' );
1228
1229 if ( !empty( $customId ) ) {
1230 if ( $botId !== null ) {
1231 $parentBotId = $botId;
1232 $botId = null;
1233 }
1234 }
1235
1236 unset( $atts['id'] );
1237 return [
1238 'chatbot' => $chatbot,
1239 'botId' => $botId,
1240 'customId' => $customId,
1241 'parentBotId' => $parentBotId
1242 ];
1243 }
1244
1245 public function chat_shortcode( $atts ) {
1246 $atts = empty( $atts ) ? [] : $atts;
1247
1248 foreach ( $atts as $key => $value ) {
1249 $atts[ $key ] = urldecode( $value );
1250 }
1251
1252 // Let the user override the chatbot params
1253 $atts = apply_filters( 'mwai_chatbot_params', $atts );
1254
1255 // Resolve the bot info
1256 $resolvedBot = $this->resolveBotInfo( $atts );
1257 if ( isset( $resolvedBot['error'] ) ) {
1258 // Config mistakes are for the people who can fix them: show the error to
1259 // editors, keep it out of visitors' pages (it used to print publicly).
1260 error_log( '[AI Engine] ' . wp_strip_all_tags( $resolvedBot['error'] ) );
1261 if ( current_user_can( 'edit_posts' ) ) {
1262 return $resolvedBot['error'];
1263 }
1264 return '';
1265 }
1266 $chatbot = $resolvedBot['chatbot'];
1267 $botId = $resolvedBot['botId'];
1268 $customId = $resolvedBot['customId'];
1269 $parentBotId = $resolvedBot['parentBotId'];
1270
1271 // Rename the keys of the atts into camelCase to match the internal params system.
1272 $atts = array_map( function ( $key, $value ) {
1273 $key = str_replace( '_', ' ', $key );
1274 $key = ucwords( $key );
1275 $key = str_replace( ' ', '', $key );
1276 $key = lcfirst( $key );
1277 return [ $key => $value ];
1278 }, array_keys( $atts ), $atts );
1279 $atts = array_merge( ...$atts );
1280
1281 if ( !empty( $parentBotId ) ) {
1282 $atts['parentBotId'] = $parentBotId;
1283 }
1284
1285 $frontParams = [];
1286 // Define text parameters that need sanitization (excluding those that support HTML)
1287 $textParams = ['aiName', 'userName', 'guestName', 'textSend', 'textClear', 'textInputPlaceholder',
1288 'startSentence', 'iconText', 'iconAlt', 'headerSubtitle', 'popupTitle', 'allowedMimeTypes', 'maxHeight', 'iconSize'];
1289 // Parameters that support HTML content
1290 $htmlParams = ['textCompliance'];
1291 // Boolean parameters that need special handling
1292 $booleanParams = ['window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1293 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1294
1295 foreach ( MWAI_CHATBOT_FRONT_PARAMS as $param ) {
1296 // Let's go through the overriden or custom params first (the ones passed in the shortcode)
1297 if ( isset( $atts[$param] ) ) {
1298 if ( $param === 'localMemory' ) {
1299 $frontParams[$param] = $atts[$param] === 'true';
1300 }
1301 else if ( in_array( $param, $textParams ) ) {
1302 // Sanitize text parameters to prevent XSS
1303 $frontParams[$param] = sanitize_text_field( $atts[$param] );
1304 }
1305 else if ( in_array( $param, $htmlParams ) ) {
1306 // For HTML parameters, use wp_kses_post to allow safe HTML
1307 $frontParams[$param] = wp_kses_post( $atts[$param] );
1308 }
1309 else if ( in_array( $param, $booleanParams ) ) {
1310 // Convert to proper boolean
1311 // Handle various boolean representations from shortcode attributes
1312 $value = $atts[$param];
1313 if ( is_bool( $value ) ) {
1314 $frontParams[$param] = $value;
1315 }
1316 else if ( is_string( $value ) ) {
1317 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0' && $value !== 'no';
1318 }
1319 else {
1320 $frontParams[$param] = !empty( $value );
1321 }
1322 }
1323 else {
1324 $frontParams[$param] = $atts[$param];
1325 }
1326 }
1327 // If not, let's use the chatbot's default values
1328 else if ( isset( $chatbot[$param] ) ) {
1329 if ( in_array( $param, $booleanParams ) ) {
1330 // Convert to proper boolean for chatbot defaults too
1331 // Handle various boolean representations
1332 $value = $chatbot[$param];
1333
1334 if ( is_bool( $value ) ) {
1335 $frontParams[$param] = $value;
1336 }
1337 else if ( is_string( $value ) ) {
1338 $frontParams[$param] = !empty( $value ) && $value !== 'false' && $value !== '0';
1339 }
1340 else {
1341 $frontParams[$param] = !empty( $value );
1342 }
1343 }
1344 else {
1345 $frontParams[$param] = $chatbot[$param];
1346 }
1347 }
1348
1349 // Apply the placeholders
1350 if ( in_array( $param, ['startSentence', 'iconText'] ) ) {
1351 $frontParams[$param] = $this->core->do_placeholders( $frontParams[$param] );
1352 }
1353 }
1354
1355 // Ensure upload params are synced
1356 // fileUpload (checkbox) determines if uploads are enabled
1357 // maxUploads (number) determines how many files can be uploaded
1358 $fileUploadEnabled = !empty( $frontParams['fileUpload'] ) || !empty( $frontParams['imageUpload'] );
1359 $maxFiles = isset( $frontParams['maxUploads'] ) ? max( 1, (int) $frontParams['maxUploads'] ) : 1;
1360
1361 // Sync all params for backward compatibility
1362 $frontParams['fileUpload'] = $fileUploadEnabled;
1363 $frontParams['imageUpload'] = $fileUploadEnabled;
1364 $frontParams['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1365 $frontParams['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1366 $frontParams['maxUploads'] = $maxFiles;
1367
1368 // Server Params
1369 // NOTE: We don't need the server params for the chatbot if there are no overrides, it means
1370 // we are using the default or a specific chatbot.
1371 $isSiteWide = $this->siteWideChatId && $botId === $this->siteWideChatId;
1372
1373 // Parameters that are purely visual/UI and shouldn't trigger custom ID
1374 $visualOnlyParams = [
1375 // Bot selectors
1376 'id', 'custom_id',
1377 // System-added params
1378 'crossSite',
1379 // Visual/UI parameters that don't affect AI behavior
1380 'aiName', 'userName', 'guestName', // Display names
1381 'aiAvatar', 'userAvatar', 'guestAvatar', 'aiAvatarUrl', 'userAvatarUrl', 'guestAvatarUrl', // Avatars
1382 'textSend', 'textClear', 'textInputPlaceholder', 'textCompliance', // UI text labels
1383 'textInputMaxLength', // Input constraint (visual)
1384 'themeId', // Theme selection
1385 'window', 'icon', 'iconText', 'iconTextDelay', 'iconAlt', 'iconPosition', // Window/icon settings
1386 'centerOpen', 'width', 'openDelay', 'iconBubble', 'windowAnimation', 'fullscreen', // Window behavior
1387 'copyButton', 'pdfButton', 'headerSubtitle', 'popupTitle', // UI features
1388 'containerType', 'headerType', 'messagesType', 'inputType', 'footerType' // UI style variants
1389 ];
1390
1391 // Remove visual-only params from override detection
1392 $attsForOverrideCheck = array_diff_key( $atts, array_flip( $visualOnlyParams ) );
1393
1394 // Only these front params affect behavior and should trigger custom ID:
1395 // - mode: chat vs. prompt mode
1396 // - startSentence: initial AI message
1397 // - localMemory: affects data persistence
1398 // - imageUpload, fileUpload, multiUpload, fileSearch: affect capabilities
1399 $behavioralFrontParams = ['mode', 'startSentence', 'localMemory', 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch'];
1400
1401 $hasServerOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), MWAI_CHATBOT_SERVER_PARAMS ) ) > 0;
1402 $hasBehavioralFrontOverrides = count( array_intersect( array_keys( $attsForOverrideCheck ), $behavioralFrontParams ) ) > 0;
1403 $hasOverrides = !$isSiteWide && ( $hasServerOverrides || $hasBehavioralFrontOverrides );
1404
1405 $serverParams = [];
1406 if ( $hasOverrides ) {
1407 // Server parameters don't need sanitization as they're processed server-side
1408 // and not rendered in HTML. They may contain code, HTML, etc. for AI context.
1409 foreach ( MWAI_CHATBOT_SERVER_PARAMS as $param ) {
1410 if ( isset( $atts[$param] ) ) {
1411 $serverParams[$param] = $atts[$param];
1412 }
1413 else {
1414 // For custom chatbots, don't inherit embeddingsEnvId from the default chatbot
1415 if ( $param === 'embeddingsEnvId' && !empty( $customId ) ) {
1416 $serverParams[$param] = '';
1417 }
1418 else {
1419 $serverParams[$param] = $chatbot[$param] ?? null;
1420 }
1421 }
1422 }
1423 }
1424
1425 // Front Params
1426 $frontSystem = $this->build_front_params( $botId, $customId );
1427
1428 // Clean Params
1429 $frontParams = $this->clean_params( $frontParams );
1430 $frontSystem = $this->clean_params( $frontSystem );
1431 $serverParams = $this->clean_params( $serverParams );
1432
1433 // Server-side: Keep the System Params
1434 if ( $hasOverrides ) {
1435 if ( empty( $customId ) ) {
1436 $customId = md5( json_encode( $serverParams ) );
1437 $frontSystem['customId'] = $customId;
1438 }
1439 set_transient( 'mwai_custom_chatbot_' . $customId, $serverParams, 60 * 60 * 24 );
1440 }
1441
1442 // Retrieve the actions, shortcuts, and blocks we want to inject at the beginning
1443 $filterParams = [
1444 'step' => 'init',
1445 'botId' => $botId,
1446 'params' => array_merge( $frontParams, $frontSystem, $serverParams )
1447 ];
1448 $actions = apply_filters( 'mwai_chatbot_actions', [], $filterParams );
1449 $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams );
1450 $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams );
1451 $frontSystem['actions'] = $this->sanitize_actions( $actions );
1452 $frontSystem['blocks'] = $this->sanitize_blocks( $blocks );
1453 $shortcuts = $this->sanitize_shortcuts( $shortcuts );
1454 $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
1455 $frontSystem['shortcuts'] = $shortcuts;
1456
1457 // Client-side: Prepare JSON for Front Params and System Params
1458 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1459 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1460 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1461 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1462 //$jsonAttributes = htmlspecialchars(json_encode($atts), ENT_QUOTES, 'UTF-8');
1463
1464 $this->enqueue_scripts( $frontParams['themeId'] ?? null );
1465
1466 return "<div class='mwai-chatbot-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1467 }
1468
1469 public function chatbot_discussions( $atts ) {
1470 $atts = empty( $atts ) ? [] : $atts;
1471
1472 // Resolve the bot info
1473 $resolvedBot = $this->resolveBotInfo( $atts );
1474 if ( isset( $resolvedBot['error'] ) ) {
1475 // Config mistakes are for the people who can fix them: show the error to
1476 // editors, keep it out of visitors' pages (it used to print publicly).
1477 error_log( '[AI Engine] ' . wp_strip_all_tags( $resolvedBot['error'] ) );
1478 if ( current_user_can( 'edit_posts' ) ) {
1479 return $resolvedBot['error'];
1480 }
1481 return '';
1482 }
1483 $chatbot = $resolvedBot['chatbot'];
1484 $botId = $resolvedBot['botId'];
1485 $customId = $resolvedBot['customId'];
1486
1487 // Rename the keys of the atts into camelCase to match the internal params system.
1488 $atts = array_map( function ( $key, $value ) {
1489 $key = str_replace( '_', ' ', $key );
1490 $key = ucwords( $key );
1491 $key = str_replace( ' ', '', $key );
1492 $key = lcfirst( $key );
1493 return [ $key => $value ];
1494 }, array_keys( $atts ), $atts );
1495 $atts = array_merge( ...$atts );
1496
1497 // Front Params
1498 $frontParams = [];
1499 // All discussion params are text params that need sanitization
1500 $textParams = ['textNewChat'];
1501
1502 foreach ( MWAI_DISCUSSIONS_FRONT_PARAMS as $param ) {
1503 if ( isset( $atts[$param] ) ) {
1504 // Sanitize text parameters
1505 $frontParams[$param] = in_array( $param, $textParams ) ? sanitize_text_field( $atts[$param] ) : $atts[$param];
1506 }
1507 else if ( isset( $chatbot[$param] ) ) {
1508 $frontParams[$param] = $chatbot[$param];
1509 }
1510 }
1511
1512 // Server Params
1513 $serverParams = [];
1514 foreach ( MWAI_DISCUSSIONS_SERVER_PARAMS as $param ) {
1515 if ( isset( $atts[$param] ) ) {
1516 $serverParams[$param] = $atts[$param];
1517 }
1518 }
1519
1520 // Front System
1521 $frontSystem = $this->build_front_params( $botId, $customId );
1522 // Get refresh interval from settings
1523 $refresh_interval = $this->core->get_option( 'chatbot_discussions_refresh_interval' );
1524 if ( $refresh_interval === 'Never' ) {
1525 $frontSystem['refreshInterval'] = 0;
1526 }
1527 elseif ( $refresh_interval === 'Manual' ) {
1528 $frontSystem['refreshInterval'] = -1;
1529 }
1530 elseif ( is_numeric( $refresh_interval ) ) {
1531 $frontSystem['refreshInterval'] = intval( $refresh_interval ) * 1000; // Convert to milliseconds
1532 }
1533 else {
1534 $frontSystem['refreshInterval'] = 5000; // Default to 5 seconds
1535 }
1536 $frontSystem['refreshInterval'] = apply_filters( 'mwai_discussions_refresh_interval', $frontSystem['refreshInterval'] );
1537
1538 // Get paging setting
1539 $paging_option = $this->core->get_option( 'chatbot_discussions_paging' );
1540 if ( $paging_option === 'None' ) {
1541 $frontSystem['paging'] = 0; // No pagination
1542 }
1543 else {
1544 $frontSystem['paging'] = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Default to 10
1545 }
1546
1547 // Get metadata settings
1548 $frontSystem['metadata'] = [
1549 'enabled' => $this->core->get_option( 'chatbot_discussions_metadata_enabled' ),
1550 'startDate' => $this->core->get_option( 'chatbot_discussions_metadata_start_date' ),
1551 'lastUpdate' => $this->core->get_option( 'chatbot_discussions_metadata_last_update' ),
1552 'messageCount' => $this->core->get_option( 'chatbot_discussions_metadata_message_count' )
1553 ];
1554
1555 // Clean Params
1556 $frontParams = $this->clean_params( $frontParams );
1557 $frontSystem = $this->clean_params( $frontSystem );
1558 $serverParams = $this->clean_params( $serverParams );
1559
1560 $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
1561 $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' );
1562 $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' );
1563 $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' );
1564
1565 return "<div class='mwai-discussions-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>";
1566 }
1567
1568 public function clean_params( &$params ) {
1569 foreach ( $params as $param => $value ) {
1570 if ( $param === 'restNonce' ) {
1571 continue;
1572 }
1573 // Skip only if value is null or an array - but not if it's false or 0
1574 if ( is_null( $value ) || is_array( $value ) ) {
1575 continue;
1576 }
1577 // Handle empty strings
1578 if ( $value === '' ) {
1579 continue;
1580 }
1581 $lowerCaseValue = is_string( $value ) ? strtolower( $value ) : '';
1582 if ( $lowerCaseValue === 'true' || $lowerCaseValue === 'false' || is_bool( $value ) ) {
1583 $params[$param] = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
1584 }
1585 else if ( is_numeric( $value ) ) {
1586 $params[$param] = filter_var( $value, FILTER_VALIDATE_FLOAT );
1587 }
1588 }
1589 return $params;
1590 }
1591
1592 }
1593