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