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