advisor.php
11 months ago
chatbot.php
11 months ago
discussions.php
11 months ago
files.php
11 months ago
gdpr.php
11 months ago
search.php
11 months ago
security.php
11 months ago
tasks.php
11 months ago
wand.php
11 months ago
chatbot.php
1047 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', 'fileSearch', 'mode', 'textInputPlaceholder', 'textInputMaxLength', 'textCompliance', 'startSentence', 'localMemory', 'themeId', 'window', 'icon', 'iconText', 'iconTextDelay', 'iconAlt', 'iconPosition', 'iconBubble', 'fullscreen', 'copyButton', 'headerSubtitle' ] ); |
| 5 | |
| 6 | define( 'MWAI_CHATBOT_SERVER_PARAMS', [ 'id', 'envId', 'scope', 'mode', 'contentAware', 'context', 'startSentence', 'embeddingsEnvId', 'embeddingsIndex', 'embeddingsNamespace', 'assistantId', 'instructions', 'resolution', 'voice', 'model', 'temperature', 'maxTokens', 'contextMaxLength', 'maxResults', 'apiKey', 'functions', 'mcpServers', 'tools', 'historyStrategy', 'previousResponseId', 'parentBotId' ] ); |
| 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 | if ( is_admin() || $hasSiteWideChat ) { |
| 41 | $themeId = null; |
| 42 | if ( $hasSiteWideChat ) { |
| 43 | $bot = $this->core->get_chatbot( $this->siteWideChatId ); |
| 44 | if ( $bot && isset( $bot['themeId'] ) ) { |
| 45 | $themeId = $bot['themeId']; |
| 46 | } |
| 47 | } |
| 48 | $this->enqueue_scripts( is_admin() ? null : $themeId ); |
| 49 | if ( $hasSiteWideChat ) { |
| 50 | // Chatbot Injection |
| 51 | add_action( 'wp_footer', [ $this, 'inject_chat' ] ); |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | public function enqueue_scripts( $themeId = null ) { |
| 57 | wp_enqueue_script( 'mwai_chatbot' ); |
| 58 | if ( $this->core->get_option( 'syntax_highlight' ) ) { |
| 59 | wp_enqueue_script( 'mwai_highlight' ); |
| 60 | } |
| 61 | if ( $themeId ) { |
| 62 | $this->core->enqueue_theme( $themeId ); |
| 63 | } |
| 64 | else { |
| 65 | $this->core->enqueue_themes(); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Helper method to create REST responses with automatic token refresh |
| 71 | * |
| 72 | * @param array $data The response data |
| 73 | * @param int $status HTTP status code |
| 74 | * @return WP_REST_Response |
| 75 | */ |
| 76 | protected function create_rest_response( $data, $status = 200 ) { |
| 77 | // Always check if we need to provide a new nonce |
| 78 | $current_nonce = $this->core->get_nonce( true ); |
| 79 | $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null; |
| 80 | |
| 81 | // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours) |
| 82 | // We'll refresh if the nonce is older than 10 hours to be safe |
| 83 | $should_refresh = false; |
| 84 | |
| 85 | if ( $request_nonce ) { |
| 86 | // Try to determine the age of the nonce |
| 87 | // WordPress uses a tick system where each tick is 12 hours |
| 88 | // If we're in the second half of the nonce's life, refresh it |
| 89 | $time = time(); |
| 90 | $nonce_tick = wp_nonce_tick(); |
| 91 | |
| 92 | // Verify if the nonce is still valid but getting old |
| 93 | $verify = wp_verify_nonce( $request_nonce, 'wp_rest' ); |
| 94 | if ( $verify === 2 ) { |
| 95 | // Nonce is valid but was generated 12-24 hours ago |
| 96 | $should_refresh = true; |
| 97 | if ( $this->core->get_option( 'debug_mode' ) ) { |
| 98 | error_log( '[MWAI] Token refresh: Nonce is 12-24 hours old, providing fresh token' ); |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // If the nonce has changed or should be refreshed, include the new one |
| 104 | if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) { |
| 105 | $data['new_token'] = $current_nonce; |
| 106 | |
| 107 | // Log if debug mode is enabled |
| 108 | if ( $this->core->get_option( 'debug_mode' ) ) { |
| 109 | error_log( '[MWAI] Token refresh: Sending new token in response' ); |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | return new WP_REST_Response( $data, $status ); |
| 114 | } |
| 115 | |
| 116 | public function rest_api_init() { |
| 117 | register_rest_route( $this->namespace, '/chats/submit', [ |
| 118 | 'methods' => 'POST', |
| 119 | 'callback' => [ $this, 'rest_chat' ], |
| 120 | 'permission_callback' => [ $this->core, 'check_rest_nonce' ] |
| 121 | ] ); |
| 122 | } |
| 123 | |
| 124 | public function basics_security_check( $botId, $customId, $newMessage, $newFileId ) { |
| 125 | if ( !$botId && !$customId ) { |
| 126 | Meow_MWAI_Logging::warn( 'The query was rejected - no botId nor id was specified.' ); |
| 127 | return false; |
| 128 | } |
| 129 | |
| 130 | if ( $newFileId ) { |
| 131 | return true; |
| 132 | } |
| 133 | |
| 134 | $length = strlen( empty( $newMessage ) ? '' : $newMessage ); |
| 135 | if ( $length < 1 ) { |
| 136 | Meow_MWAI_Logging::warn( 'The query was rejected - message was too short.' ); |
| 137 | return false; |
| 138 | } |
| 139 | return true; |
| 140 | } |
| 141 | |
| 142 | public function build_final_res( $botId, $newMessage, $newFileId, $params, $reply, $images, $actions, $usage, $responseId = null ) { |
| 143 | $filterParams = [ |
| 144 | 'step' => 'reply', |
| 145 | 'botId' => $botId, |
| 146 | 'reply' => $reply, |
| 147 | 'images' => $images, |
| 148 | 'newMessage' => $newMessage, |
| 149 | 'newFileId' => $newFileId, |
| 150 | 'params' => $params, |
| 151 | 'usage' => $usage, |
| 152 | 'messages' => $params['messages'] ?? [], |
| 153 | 'isNewConversation' => empty( $params['messages'] ) || count( $params['messages'] ) <= 1, |
| 154 | ]; |
| 155 | $actions = apply_filters( 'mwai_chatbot_actions', $actions, $filterParams ); |
| 156 | $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams ); |
| 157 | $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams ); |
| 158 | $actions = $this->sanitize_actions( $actions ); |
| 159 | $blocks = $this->sanitize_blocks( $blocks ); |
| 160 | $shortcuts = $this->sanitize_shortcuts( $shortcuts ); |
| 161 | $result = [ |
| 162 | 'success' => true, |
| 163 | 'reply' => $reply, |
| 164 | 'images' => $images, |
| 165 | 'actions' => $actions, |
| 166 | 'shortcuts' => $shortcuts, |
| 167 | 'blocks' => $blocks, |
| 168 | 'usage' => $usage |
| 169 | ]; |
| 170 | |
| 171 | // Add response ID if available |
| 172 | if ( !empty( $responseId ) ) { |
| 173 | $result['responseId'] = $responseId; |
| 174 | } |
| 175 | |
| 176 | // Check if token needs refresh |
| 177 | $current_nonce = $this->core->get_nonce( true ); |
| 178 | $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null; |
| 179 | |
| 180 | $should_refresh = false; |
| 181 | if ( $request_nonce ) { |
| 182 | $verify = wp_verify_nonce( $request_nonce, 'wp_rest' ); |
| 183 | if ( $verify === 2 ) { |
| 184 | // Nonce is valid but was generated 12-24 hours ago |
| 185 | $should_refresh = true; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) { |
| 190 | $result['new_token'] = $current_nonce; |
| 191 | } |
| 192 | |
| 193 | return $result; |
| 194 | } |
| 195 | |
| 196 | public function rest_chat( $request ) { |
| 197 | $params = $request->get_json_params(); |
| 198 | $botId = $params['botId'] ?? null; |
| 199 | $customId = $params['customId'] ?? null; |
| 200 | $stream = $params['stream'] ?? false; |
| 201 | $newMessage = trim( $params['newMessage'] ?? '' ); |
| 202 | $newFileId = $params['newFileId'] ?? null; |
| 203 | |
| 204 | if ( !$this->basics_security_check( $botId, $customId, $newMessage, $newFileId ) ) { |
| 205 | return $this->create_rest_response( [ |
| 206 | 'success' => false, |
| 207 | 'message' => apply_filters( 'mwai_ai_exception', 'Sorry, your query has been rejected.' ) |
| 208 | ], 403 ); |
| 209 | } |
| 210 | |
| 211 | try { |
| 212 | $data = $this->chat_submit( $botId, $newMessage, $newFileId, $params, $stream ); |
| 213 | $final_res = $this->build_final_res( |
| 214 | $botId, |
| 215 | $newMessage, |
| 216 | $newFileId, |
| 217 | $params, |
| 218 | $data['reply'], |
| 219 | $data['images'], |
| 220 | $data['actions'], |
| 221 | $data['usage'], |
| 222 | $data['responseId'] ?? null |
| 223 | ); |
| 224 | return $this->create_rest_response( $final_res, 200 ); |
| 225 | } |
| 226 | catch ( Exception $e ) { |
| 227 | $message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); |
| 228 | return $this->create_rest_response( [ |
| 229 | 'success' => false, |
| 230 | 'message' => $message |
| 231 | ], 500 ); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | private function sanitize_items( $items, $supported_types, $type_name ) { |
| 236 | if ( empty( $items ) ) { |
| 237 | return $items; |
| 238 | } |
| 239 | $sanitized_items = []; |
| 240 | foreach ( $items as $item ) { |
| 241 | if ( isset( $supported_types[$item['type']] ) ) { |
| 242 | $is_valid = true; |
| 243 | foreach ( $supported_types[$item['type']] as $param ) { |
| 244 | if ( !isset( $item['data'][$param] ) ) { |
| 245 | $is_valid = false; |
| 246 | Meow_MWAI_Logging::warn( "The query was rejected - missing required parameter '{$param}' for {$type_name} type: {$item['type']}." ); |
| 247 | break; |
| 248 | } |
| 249 | } |
| 250 | if ( $is_valid ) { |
| 251 | $sanitized_items[] = $item; |
| 252 | } |
| 253 | } |
| 254 | else { |
| 255 | Meow_MWAI_Logging::warn( "The query was rejected - unsupported {$type_name} type: {$item['type']}." ); |
| 256 | } |
| 257 | } |
| 258 | return $sanitized_items; |
| 259 | } |
| 260 | |
| 261 | public function sanitize_actions( $actions ) { |
| 262 | $supported_action_types = [ |
| 263 | 'function' => ['name', 'args'], |
| 264 | 'javascript' => ['snippet'], |
| 265 | ]; |
| 266 | return $this->sanitize_items( $actions, $supported_action_types, 'action' ); |
| 267 | } |
| 268 | |
| 269 | public function sanitize_blocks( $blocks ) { |
| 270 | $supported_block_types = [ |
| 271 | 'content' => ['html'], |
| 272 | ]; |
| 273 | return $this->sanitize_items( $blocks, $supported_block_types, 'block' ); |
| 274 | } |
| 275 | |
| 276 | public function sanitize_shortcuts( $shortcuts ) { |
| 277 | $supported_shortcut_types = [ |
| 278 | 'message' => ['label', 'message'], |
| 279 | 'callback' => ['label', 'onClick'], |
| 280 | ]; |
| 281 | return $this->sanitize_items( $shortcuts, $supported_shortcut_types, 'shortcut' ); |
| 282 | } |
| 283 | |
| 284 | #region Messages Integrity Check |
| 285 | |
| 286 | public function messages_integrity_diff( $messages1, $messages2 ) { |
| 287 | // Collect messages with role not 'user' from messages1 |
| 288 | $messagesList1 = []; |
| 289 | foreach ( $messages1 as $msg ) { |
| 290 | $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null ); |
| 291 | $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null ); |
| 292 | if ( $role && $role != 'user' ) { |
| 293 | $messageData = [ 'role' => $role, 'content' => $content ]; |
| 294 | $messagesList1[] = $messageData; |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | // Collect messages with role not 'user' from messages2 |
| 299 | $messagesList2 = []; |
| 300 | foreach ( $messages2 as $msg ) { |
| 301 | $role = isset( $msg->role ) ? $msg->role : ( isset( $msg['role'] ) ? $msg['role'] : null ); |
| 302 | $content = isset( $msg->content ) ? $msg->content : ( isset( $msg['content'] ) ? $msg['content'] : null ); |
| 303 | if ( $role && $role != 'user' ) { |
| 304 | $messageData = [ 'role' => $role, 'content' => $content ]; |
| 305 | $messagesList2[] = $messageData; |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | // Count occurrences of each message in messagesList1 |
| 310 | $counts1 = []; |
| 311 | foreach ( $messagesList1 as $msg ) { |
| 312 | $key = serialize( $msg ); |
| 313 | if ( isset( $counts1[ $key ] ) ) { |
| 314 | $counts1[ $key ]++; |
| 315 | } |
| 316 | else { |
| 317 | $counts1[ $key ] = 1; |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // Count occurrences of each message in messagesList2 |
| 322 | $counts2 = []; |
| 323 | foreach ( $messagesList2 as $msg ) { |
| 324 | $key = serialize( $msg ); |
| 325 | if ( isset( $counts2[ $key ] ) ) { |
| 326 | $counts2[ $key ]++; |
| 327 | } |
| 328 | else { |
| 329 | $counts2[ $key ] = 1; |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // Compare counts to find unmatched messages |
| 334 | $all_keys = array_unique( array_merge( array_keys( $counts1 ), array_keys( $counts2 ) ) ); |
| 335 | |
| 336 | $diffs = []; |
| 337 | foreach ( $all_keys as $key ) { |
| 338 | $count1 = isset( $counts1[ $key ] ) ? $counts1[ $key ] : 0; |
| 339 | $count2 = isset( $counts2[ $key ] ) ? $counts2[ $key ] : 0; |
| 340 | if ( $count1 != $count2 ) { |
| 341 | $message = unserialize( $key ); |
| 342 | $diffs[] = [ |
| 343 | 'message' => $message, |
| 344 | 'count_in_messages1' => $count1, |
| 345 | 'count_in_messages2' => $count2 |
| 346 | ]; |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | return $diffs; |
| 351 | } |
| 352 | |
| 353 | private function calculate_messages_checksum( $messages ) { |
| 354 | $messages_to_hash = []; |
| 355 | foreach ( $messages as $msg ) { |
| 356 | $role = is_array( $msg ) ? ( $msg['role'] ?? '' ) : ( is_object( $msg ) ? ( $msg->role ?? '' ) : '' ); |
| 357 | $content = is_array( $msg ) ? ( $msg['content'] ?? '' ) : ( is_object( $msg ) ? ( $msg->content ?? '' ) : '' ); |
| 358 | if ( in_array( $role, ['assistant', 'system'] ) ) { |
| 359 | $messages_to_hash[] = [ 'role' => $role, 'content' => $content ]; |
| 360 | } |
| 361 | } |
| 362 | return md5( json_encode( $messages_to_hash ) ); |
| 363 | } |
| 364 | |
| 365 | #endregion |
| 366 | |
| 367 | public function chat_submit( $botId, $newMessage, $newFileId = null, $params = [], $stream = false ) { |
| 368 | try { |
| 369 | $chatbot = null; |
| 370 | $customId = $params['customId'] ?? null; |
| 371 | |
| 372 | // Custom Chatbot |
| 373 | if ( $customId ) { |
| 374 | $chatbot = get_transient( 'mwai_custom_chatbot_' . $customId ); |
| 375 | } |
| 376 | // Registered Chatbot |
| 377 | if ( !$chatbot && $botId ) { |
| 378 | $chatbot = $this->core->get_chatbot( $botId ); |
| 379 | } |
| 380 | |
| 381 | if ( !$chatbot ) { |
| 382 | Meow_MWAI_Logging::warn( 'The query was rejected - no chatbot was found.' ); |
| 383 | throw new Exception( 'Sorry, your query has been rejected.' ); |
| 384 | } |
| 385 | |
| 386 | $textInputMaxLength = $chatbot['textInputMaxLength'] ?? null; |
| 387 | if ( $textInputMaxLength && $this->core->safe_strlen( $newMessage ) > (int) $textInputMaxLength ) { |
| 388 | Meow_MWAI_Logging::warn( 'The query was rejected - message was too long.' ); |
| 389 | throw new Exception( 'Sorry, your query has been rejected.' ); |
| 390 | } |
| 391 | |
| 392 | // We need to check the integrity of the messages sent by the client. |
| 393 | // This is important to ensure that the messages are not tampered with. |
| 394 | |
| 395 | // Messages Integrity Check with Checksums |
| 396 | $chatId = $params['chatId'] ?? 'default'; |
| 397 | $checksum_key = 'mwai_chatbot_checksum_' . $chatId; |
| 398 | $stored_checksum = get_transient( $checksum_key ); |
| 399 | $client_messages = $params['messages'] ?? []; |
| 400 | $client_checksum = $this->calculate_messages_checksum( $client_messages ); |
| 401 | if ( $stored_checksum && $stored_checksum !== $client_checksum ) { |
| 402 | 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.' ); |
| 403 | } |
| 404 | |
| 405 | // Messages Integrity Check with Discussions |
| 406 | if ( $this->core->get_option( 'chatbot_discussions' ) ) { |
| 407 | $discussion = $this->core->discussions->get_discussion( $botId ? $botId : $customId, $params['chatId'] ); |
| 408 | if ( $discussion ) { |
| 409 | $messages = $discussion['messages']; |
| 410 | $diffs = $this->messages_integrity_diff( $messages, $params['messages'] ); |
| 411 | if ( count( $diffs ) > 0 ) { |
| 412 | Meow_MWAI_Logging::warn( "Integrity Check: It seems the messages in the discussion #{$discussion['id']} do not match the ones sent by the client." ); |
| 413 | } |
| 414 | |
| 415 | // Maintain conversation state for Responses API by loading previousResponseId |
| 416 | // This enables stateful conversations where only new messages are sent |
| 417 | if ( empty( $params['previousResponseId'] ) && !empty( $discussion['extra'] ) ) { |
| 418 | $extra = json_decode( $discussion['extra'], true ); |
| 419 | if ( !empty( $extra['responseId'] ) ) { |
| 420 | // Response IDs expire after 30 days per OpenAI's policy |
| 421 | // Check if the stored response is still valid |
| 422 | $responseDate = !empty( $extra['responseDate'] ) ? strtotime( $extra['responseDate'] ) : 0; |
| 423 | $thirtyDaysAgo = time() - ( 30 * 24 * 60 * 60 ); |
| 424 | |
| 425 | if ( $responseDate > $thirtyDaysAgo ) { |
| 426 | // Use the stored response ID for stateful conversation |
| 427 | $params['previousResponseId'] = $extra['responseId']; |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | else { |
| 433 | // No discussion yet? We still need to check the startSentence. |
| 434 | $startSentence = isset( $chatbot['startSentence'] ) ? $chatbot['startSentence'] : null; |
| 435 | $messages = []; |
| 436 | if ( !empty( $startSentence ) ) { |
| 437 | $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ]; |
| 438 | } |
| 439 | $diffs = $this->messages_integrity_diff( $messages, $params['messages'] ); |
| 440 | if ( count( $diffs ) > 0 ) { |
| 441 | 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 ) ); |
| 442 | } |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | // Create QueryText |
| 447 | $context = null; |
| 448 | $streamCallback = null; |
| 449 | $mode = $chatbot['mode'] ?? 'chat'; |
| 450 | |
| 451 | if ( $mode === 'images' ) { |
| 452 | // If there's an uploaded file, use EditImage query instead |
| 453 | if ( !empty( $newFileId ) ) { |
| 454 | $query = new Meow_MWAI_Query_EditImage( $newMessage ); |
| 455 | |
| 456 | // Handle the uploaded image |
| 457 | $url = $this->core->files->get_url( $newFileId ); |
| 458 | $mimeType = $this->core->files->get_mime_type( $newFileId ); |
| 459 | $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] ); |
| 460 | |
| 461 | if ( $isIMG ) { |
| 462 | $query->set_file( Meow_MWAI_Query_DroppedFile::from_url( $url, 'vision', $mimeType ) ); |
| 463 | $fileId = $this->core->files->get_id_from_refId( $newFileId ); |
| 464 | $this->core->files->update_purpose( $fileId, 'vision' ); |
| 465 | } |
| 466 | } |
| 467 | else { |
| 468 | $query = new Meow_MWAI_Query_Image( $newMessage ); |
| 469 | } |
| 470 | |
| 471 | // Handle Params |
| 472 | $newParams = []; |
| 473 | foreach ( $chatbot as $key => $value ) { |
| 474 | $newParams[$key] = $value; |
| 475 | } |
| 476 | foreach ( $params as $key => $value ) { |
| 477 | $newParams[$key] = $value; |
| 478 | } |
| 479 | $params = apply_filters( 'mwai_chatbot_params', $newParams ); |
| 480 | $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope']; |
| 481 | |
| 482 | $query->inject_params( $params ); |
| 483 | } |
| 484 | else { |
| 485 | $query = $mode === 'assistant' ? new Meow_MWAI_Query_Assistant( $newMessage ) : |
| 486 | new Meow_MWAI_Query_Text( $newMessage, 1024 ); |
| 487 | |
| 488 | // Handle Params |
| 489 | $newParams = []; |
| 490 | foreach ( $chatbot as $key => $value ) { |
| 491 | $newParams[$key] = $value; |
| 492 | } |
| 493 | foreach ( $params as $key => $value ) { |
| 494 | $newParams[$key] = $value; |
| 495 | } |
| 496 | $params = apply_filters( 'mwai_chatbot_params', $newParams ); |
| 497 | $params['scope'] = empty( $params['scope'] ) ? 'chatbot' : $params['scope']; |
| 498 | |
| 499 | $query->inject_params( $params ); |
| 500 | |
| 501 | $storeId = null; |
| 502 | if ( $mode === 'assistant' ) { |
| 503 | $chatId = $params['chatId'] ?? null; |
| 504 | if ( !empty( $chatId ) ) { |
| 505 | $discussion = $this->core->discussions->get_discussion( $query->botId, $chatId ); |
| 506 | if ( isset( $discussion['storeId'] ) ) { |
| 507 | $storeId = $discussion['storeId']; |
| 508 | $query->setStoreId( $storeId ); |
| 509 | } |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | // Support for Uploaded Image |
| 514 | if ( !empty( $newFileId ) ) { |
| 515 | |
| 516 | // Get extension and mime type |
| 517 | $isImage = $this->core->files->is_image( $newFileId ); |
| 518 | |
| 519 | if ( $mode === 'assistant' && !$isImage ) { |
| 520 | $url = $this->core->files->get_path( $newFileId ); |
| 521 | $data = $this->core->files->get_data( $newFileId ); |
| 522 | $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId ); |
| 523 | $filename = basename( $url ); |
| 524 | |
| 525 | // Upload the file |
| 526 | $file = $openai->upload_file( $filename, $data, 'assistants' ); |
| 527 | |
| 528 | // Create a store |
| 529 | if ( empty( $storeId ) ) { |
| 530 | $chatbotName = 'mwai_' . strtolower( !empty( $chatbot['name'] ) ? $chatbot['name'] : 'default' ); |
| 531 | if ( !empty( $query->chatId ) ) { |
| 532 | $chatbotName .= '_' . $query->chatId; |
| 533 | } |
| 534 | $metadata = []; |
| 535 | if ( !empty( $chatbot['assistantId'] ) ) { |
| 536 | $metadata['assistantId'] = $chatbot['assistantId']; |
| 537 | } |
| 538 | if ( !empty( $query->chatId ) ) { |
| 539 | $metadata['chatId'] = $query->chatId; |
| 540 | } |
| 541 | $expiry = $this->core->get_option( 'image_expires' ); |
| 542 | $storeId = $openai->create_vector_store( $chatbotName, $expiry, $metadata ); |
| 543 | $query->setStoreId( $storeId ); |
| 544 | } |
| 545 | |
| 546 | // Add the file to the store |
| 547 | $storeFileId = $openai->add_vector_store_file( $storeId, $file['id'] ); |
| 548 | |
| 549 | // Update the local file with the OpenAI RefId, StoreId and StoreFileId |
| 550 | $openAiRefId = $file['id']; |
| 551 | $internalFileId = $this->core->files->get_id_from_refId( $newFileId ); |
| 552 | $this->core->files->update_refId( $internalFileId, $openAiRefId ); |
| 553 | $this->core->files->update_envId( $internalFileId, $query->envId ); |
| 554 | $this->core->files->update_purpose( $internalFileId, 'assistant-in' ); |
| 555 | $this->core->files->add_metadata( $internalFileId, 'assistant_storeId', $storeId ); |
| 556 | $this->core->files->add_metadata( $internalFileId, 'assistant_storeFileId', $storeFileId ); |
| 557 | $newFileId = $openAiRefId; |
| 558 | $scope = $params['fileSearch']; |
| 559 | if ( $scope === 'discussion' || $scope === 'user' || $scope === 'assistant' ) { |
| 560 | $id = $this->core->files->get_id_from_refId( $newFileId ); |
| 561 | $this->core->files->add_metadata( $id, 'assistant_scope', $scope ); |
| 562 | } |
| 563 | } |
| 564 | else { |
| 565 | $url = $this->core->files->get_url( $newFileId ); |
| 566 | $mimeType = $this->core->files->get_mime_type( $newFileId ); |
| 567 | $isIMG = in_array( $mimeType, [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ] ); |
| 568 | $purposeType = $isIMG ? 'vision' : 'files'; |
| 569 | $query->set_file( Meow_MWAI_Query_DroppedFile::from_url( $url, $purposeType, $mimeType ) ); |
| 570 | $fileId = $this->core->files->get_id_from_refId( $newFileId ); |
| 571 | $this->core->files->update_envId( $fileId, $query->envId ); |
| 572 | $this->core->files->update_purpose( $fileId, $purposeType ); |
| 573 | $this->core->files->add_metadata( $fileId, 'query_envId', $query->envId ); |
| 574 | $this->core->files->add_metadata( $fileId, 'query_session', $query->session ); |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | // Takeover |
| 579 | $takeoverAnswer = apply_filters( 'mwai_chatbot_takeover', null, $query, $params ); |
| 580 | if ( !empty( $takeoverAnswer ) ) { |
| 581 | $rawText = apply_filters( 'mwai_chatbot_reply', $takeoverAnswer, $query, $params, [] ); |
| 582 | return [ |
| 583 | 'reply' => $rawText, |
| 584 | 'chatId' => $this->core->fix_chat_id( $query, $params ), |
| 585 | 'images' => null, |
| 586 | 'actions' => [], |
| 587 | 'usage' => null |
| 588 | ]; |
| 589 | } |
| 590 | |
| 591 | // Moderation |
| 592 | $moderationEnabled = $this->core->get_option( 'module_moderation' ) && |
| 593 | $this->core->get_option( 'shortcode_chat_moderation' ); |
| 594 | if ( $moderationEnabled ) { |
| 595 | global $mwai; |
| 596 | $isFlagged = $mwai->moderationCheck( $query->get_message() ); |
| 597 | if ( $isFlagged ) { |
| 598 | throw new Exception( 'Sorry, your message has been rejected by moderation.' ); |
| 599 | } |
| 600 | } |
| 601 | |
| 602 | // Setup streaming if enabled (before embeddings to capture those events) |
| 603 | $streamCallback = null; |
| 604 | $debugEvents = []; |
| 605 | |
| 606 | if ( $stream ) { |
| 607 | $streamCallback = function ( $reply ) use ( $query ) { |
| 608 | // Support both legacy string data and new Event objects |
| 609 | if ( is_string( $reply ) ) { |
| 610 | $this->core->stream_push( [ 'type' => 'live', 'data' => $reply ], $query ); |
| 611 | } |
| 612 | else { |
| 613 | $this->core->stream_push( $reply, $query ); |
| 614 | } |
| 615 | }; |
| 616 | if ( headers_sent( $filename, $linenum ) ) { |
| 617 | throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." ); |
| 618 | } |
| 619 | header( 'Cache-Control: no-cache' ); |
| 620 | header( 'Content-Type: text/event-stream' ); |
| 621 | // This is useful to disable buffering in nginx through headers. |
| 622 | header( 'X-Accel-Buffering: no' ); |
| 623 | ob_implicit_flush( true ); |
| 624 | if ( ob_get_level() > 0 ) { |
| 625 | ob_end_flush(); |
| 626 | } |
| 627 | } |
| 628 | else if ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ) { |
| 629 | // For non-streaming debug mode, collect events |
| 630 | $streamCallback = function ( $event ) use ( &$debugEvents ) { |
| 631 | if ( is_object( $event ) && method_exists( $event, 'toArray' ) ) { |
| 632 | $debugEvents[] = $event->toArray(); |
| 633 | } |
| 634 | }; |
| 635 | } |
| 636 | |
| 637 | // Awareness & Embeddings |
| 638 | $context = $this->core->retrieve_context( $params, $query, $streamCallback ); |
| 639 | if ( !empty( $context ) ) { |
| 640 | $query->set_context( $context['content'] ); |
| 641 | } |
| 642 | |
| 643 | // Function Aware |
| 644 | $query = apply_filters( 'mwai_chatbot_query', $query, $params ); |
| 645 | } |
| 646 | |
| 647 | // Process Query |
| 648 | |
| 649 | $reply = $this->core->run_query( $query, $streamCallback, true ); |
| 650 | $rawText = $reply->result; |
| 651 | $extra = []; |
| 652 | if ( $context ) { |
| 653 | $extra = [ 'embeddings' => isset( $context['embeddings'] ) ? $context['embeddings'] : null ]; |
| 654 | } |
| 655 | // Store response ID for Responses API stateful conversations |
| 656 | // CRITICAL: Must store even when function calls are present |
| 657 | // This enables the feedback query to use previous_response_id |
| 658 | if ( !empty( $reply->id ) ) { |
| 659 | $extra['responseId'] = $reply->id; |
| 660 | $extra['responseDate'] = gmdate( 'Y-m-d H:i:s' ); // Track age for 30-day expiry |
| 661 | } |
| 662 | $rawText = apply_filters( 'mwai_chatbot_reply', $rawText, $query, $params, $extra ); |
| 663 | |
| 664 | // Integrity Check: We need to store the checksum of the messages sent by the client. |
| 665 | $stored_messages = $client_messages; |
| 666 | $stored_messages[] = [ 'role' => 'user', 'content' => $newMessage ]; |
| 667 | $stored_messages[] = [ 'role' => 'assistant', 'content' => $rawText ]; |
| 668 | $stored_checksum = $this->calculate_messages_checksum( $stored_messages ); |
| 669 | set_transient( $checksum_key, $stored_checksum, 60 * 60 * 24 * 30 ); |
| 670 | |
| 671 | // Actions |
| 672 | $actions = []; |
| 673 | if ( $reply->needClientActions ) { |
| 674 | foreach ( $reply->needClientActions as $action ) { |
| 675 | $actions[] = [ |
| 676 | 'type' => 'function', |
| 677 | 'data' => [ |
| 678 | 'name' => $action['function']->name, |
| 679 | 'args' => $action['arguments'] |
| 680 | ] |
| 681 | ]; |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | $restRes = [ |
| 686 | 'reply' => $rawText, |
| 687 | 'chatId' => $this->core->fix_chat_id( $query, $params ), |
| 688 | 'images' => $reply->get_type() === 'images' ? $reply->results : null, |
| 689 | 'actions' => $actions, |
| 690 | 'usage' => $reply->usage |
| 691 | ]; |
| 692 | |
| 693 | // Add debug events if collected |
| 694 | if ( !empty( $debugEvents ) ) { |
| 695 | $restRes['debugEvents'] = $debugEvents; |
| 696 | } |
| 697 | |
| 698 | // Add response ID if available (for Responses API) |
| 699 | if ( !empty( $reply->id ) ) { |
| 700 | $restRes['responseId'] = $reply->id; |
| 701 | } |
| 702 | |
| 703 | // Process Reply |
| 704 | if ( $stream ) { |
| 705 | $final_res = $this->build_final_res( |
| 706 | $botId, |
| 707 | $newMessage, |
| 708 | $newFileId, |
| 709 | $params, |
| 710 | $restRes['reply'], |
| 711 | $restRes['images'], |
| 712 | $restRes['actions'], |
| 713 | $restRes['usage'], |
| 714 | $restRes['responseId'] ?? null |
| 715 | ); |
| 716 | $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $final_res ) ], $query ); |
| 717 | die(); |
| 718 | } |
| 719 | else { |
| 720 | return $restRes; |
| 721 | } |
| 722 | |
| 723 | } |
| 724 | catch ( Exception $e ) { |
| 725 | $message = apply_filters( 'mwai_ai_exception', $e->getMessage() ); |
| 726 | if ( $stream ) { |
| 727 | $this->core->stream_push( [ 'type' => 'error', 'data' => $message ], $query ); |
| 728 | die(); |
| 729 | } |
| 730 | else { |
| 731 | throw $e; |
| 732 | } |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | public function inject_chat() { |
| 737 | $params = $this->core->get_chatbot( $this->siteWideChatId ); |
| 738 | $clean_params = []; |
| 739 | if ( !empty( $params ) ) { |
| 740 | $clean_params['window'] = true; |
| 741 | $clean_params['id'] = $this->siteWideChatId; |
| 742 | echo $this->chat_shortcode( $clean_params ); |
| 743 | } |
| 744 | return null; |
| 745 | } |
| 746 | |
| 747 | public function build_front_params( $botId, $customId ) { |
| 748 | $frontSystem = [ |
| 749 | 'botId' => $customId ? null : sanitize_text_field( $botId ), |
| 750 | 'customId' => sanitize_text_field( $customId ), |
| 751 | 'userData' => $this->core->get_user_data(), |
| 752 | 'sessionId' => $this->core->get_session_id(), |
| 753 | // IMPORTANT: REST nonce handling differs by user state: |
| 754 | // - Logged-in users: get_nonce() returns a user-specific nonce created in current session context |
| 755 | // - Logged-out users: get_nonce() returns null, they'll fetch via /start_session endpoint |
| 756 | // This prevents rest_cookie_invalid_nonce errors for logged-in users by ensuring the nonce |
| 757 | // matches their authentication context from the start. |
| 758 | 'restNonce' => $this->core->get_nonce(), |
| 759 | 'contextId' => get_the_ID(), |
| 760 | 'pluginUrl' => MWAI_URL, |
| 761 | 'restUrl' => untrailingslashit( get_rest_url() ), |
| 762 | 'stream' => $this->core->get_option( 'ai_streaming' ), |
| 763 | 'debugMode' => $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ), |
| 764 | 'eventLogs' => $this->core->get_option( 'event_logs' ), |
| 765 | 'speech_recognition' => $this->core->get_option( 'speech_recognition' ), |
| 766 | 'speech_synthesis' => $this->core->get_option( 'speech_synthesis' ), |
| 767 | 'typewriter' => $this->core->get_option( 'chatbot_typewriter' ), |
| 768 | 'virtual_keyboard_fix' => $this->core->get_option( 'virtual_keyboard_fix' ) |
| 769 | ]; |
| 770 | return $frontSystem; |
| 771 | } |
| 772 | |
| 773 | public function resolveBotInfo( &$atts ) { |
| 774 | $chatbot = null; |
| 775 | $botId = $atts['id'] ?? null; |
| 776 | $customId = $atts['custom_id'] ?? null; |
| 777 | $parentBotId = null; |
| 778 | |
| 779 | if ( !$botId && !$customId ) { |
| 780 | $botId = 'default'; |
| 781 | } |
| 782 | if ( $botId ) { |
| 783 | $chatbot = $this->core->get_chatbot( $botId ); |
| 784 | if ( !$chatbot ) { |
| 785 | $botId = $botId ?: 'N/A'; |
| 786 | $safe_botId = esc_html( $botId ); |
| 787 | return [ |
| 788 | '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'.", |
| 789 | ]; |
| 790 | } |
| 791 | } |
| 792 | $chatbot = $chatbot ?: $this->core->get_chatbot( 'default' ); |
| 793 | if ( !empty( $customId ) ) { |
| 794 | if ( $botId !== null ) { |
| 795 | $parentBotId = $botId; |
| 796 | $botId = null; |
| 797 | } |
| 798 | } |
| 799 | unset( $atts['id'] ); |
| 800 | return [ |
| 801 | 'chatbot' => $chatbot, |
| 802 | 'botId' => $botId, |
| 803 | 'customId' => $customId, |
| 804 | 'parentBotId' => $parentBotId |
| 805 | ]; |
| 806 | } |
| 807 | |
| 808 | public function chat_shortcode( $atts ) { |
| 809 | $atts = empty( $atts ) ? [] : $atts; |
| 810 | |
| 811 | foreach ( $atts as $key => $value ) { |
| 812 | $atts[ $key ] = urldecode( $value ); |
| 813 | } |
| 814 | |
| 815 | // Let the user override the chatbot params |
| 816 | $atts = apply_filters( 'mwai_chatbot_params', $atts ); |
| 817 | |
| 818 | // Resolve the bot info |
| 819 | $resolvedBot = $this->resolveBotInfo( $atts, 'chatbot' ); |
| 820 | if ( isset( $resolvedBot['error'] ) ) { |
| 821 | return $resolvedBot['error']; |
| 822 | } |
| 823 | $chatbot = $resolvedBot['chatbot']; |
| 824 | $botId = $resolvedBot['botId']; |
| 825 | $customId = $resolvedBot['customId']; |
| 826 | $parentBotId = $resolvedBot['parentBotId']; |
| 827 | |
| 828 | // Rename the keys of the atts into camelCase to match the internal params system. |
| 829 | $atts = array_map( function ( $key, $value ) { |
| 830 | $key = str_replace( '_', ' ', $key ); |
| 831 | $key = ucwords( $key ); |
| 832 | $key = str_replace( ' ', '', $key ); |
| 833 | $key = lcfirst( $key ); |
| 834 | return [ $key => $value ]; |
| 835 | }, array_keys( $atts ), $atts ); |
| 836 | $atts = array_merge( ...$atts ); |
| 837 | |
| 838 | if ( !empty( $parentBotId ) ) { |
| 839 | $atts['parentBotId'] = $parentBotId; |
| 840 | } |
| 841 | |
| 842 | $frontParams = []; |
| 843 | // Define text parameters that need sanitization (excluding those that support HTML) |
| 844 | $textParams = ['aiName', 'userName', 'guestName', 'textSend', 'textClear', 'textInputPlaceholder', |
| 845 | 'startSentence', 'iconText', 'iconAlt', 'headerSubtitle']; |
| 846 | // Parameters that support HTML content |
| 847 | $htmlParams = ['textCompliance']; |
| 848 | |
| 849 | foreach ( MWAI_CHATBOT_FRONT_PARAMS as $param ) { |
| 850 | // Let's go through the overriden or custom params first (the ones passed in the shortcode) |
| 851 | if ( isset( $atts[$param] ) ) { |
| 852 | if ( $param === 'localMemory' ) { |
| 853 | $frontParams[$param] = $atts[$param] === 'true'; |
| 854 | } |
| 855 | else if ( in_array( $param, $textParams ) ) { |
| 856 | // Sanitize text parameters to prevent XSS |
| 857 | $frontParams[$param] = sanitize_text_field( $atts[$param] ); |
| 858 | } |
| 859 | else if ( in_array( $param, $htmlParams ) ) { |
| 860 | // For HTML parameters, use wp_kses_post to allow safe HTML |
| 861 | $frontParams[$param] = wp_kses_post( $atts[$param] ); |
| 862 | } |
| 863 | else { |
| 864 | $frontParams[$param] = $atts[$param]; |
| 865 | } |
| 866 | } |
| 867 | // If not, let's use the chatbot's default values |
| 868 | else if ( isset( $chatbot[$param] ) ) { |
| 869 | $frontParams[$param] = $chatbot[$param]; |
| 870 | } |
| 871 | |
| 872 | // Apply the placeholders |
| 873 | if ( in_array( $param, ['startSentence', 'iconText'] ) ) { |
| 874 | $frontParams[$param] = $this->core->do_placeholders( $frontParams[$param] ); |
| 875 | } |
| 876 | } |
| 877 | |
| 878 | // Server Params |
| 879 | // NOTE: We don't need the server params for the chatbot if there are no overrides, it means |
| 880 | // we are using the default or a specific chatbot. |
| 881 | $isSiteWide = $this->siteWideChatId && $botId === $this->siteWideChatId; |
| 882 | $hasServerOverrides = count( array_intersect( array_keys( $atts ), MWAI_CHATBOT_SERVER_PARAMS ) ) > 0; |
| 883 | $hasFrontOverrides = count( array_intersect( array_keys( $atts ), MWAI_CHATBOT_FRONT_PARAMS ) ) > 0; |
| 884 | $hasOverrides = !$isSiteWide && ( $hasServerOverrides || $hasFrontOverrides ); |
| 885 | |
| 886 | $serverParams = []; |
| 887 | if ( $hasOverrides ) { |
| 888 | // Server parameters don't need sanitization as they're processed server-side |
| 889 | // and not rendered in HTML. They may contain code, HTML, etc. for AI context. |
| 890 | foreach ( MWAI_CHATBOT_SERVER_PARAMS as $param ) { |
| 891 | if ( isset( $atts[$param] ) ) { |
| 892 | $serverParams[$param] = $atts[$param]; |
| 893 | } |
| 894 | else { |
| 895 | $serverParams[$param] = $chatbot[$param] ?? null; |
| 896 | } |
| 897 | } |
| 898 | } |
| 899 | |
| 900 | // Front Params |
| 901 | $frontSystem = $this->build_front_params( $botId, $customId ); |
| 902 | |
| 903 | // Clean Params |
| 904 | $frontParams = $this->clean_params( $frontParams ); |
| 905 | $frontSystem = $this->clean_params( $frontSystem ); |
| 906 | $serverParams = $this->clean_params( $serverParams ); |
| 907 | |
| 908 | // Server-side: Keep the System Params |
| 909 | if ( $hasOverrides ) { |
| 910 | if ( empty( $customId ) ) { |
| 911 | $customId = md5( json_encode( $serverParams ) ); |
| 912 | $frontSystem['customId'] = $customId; |
| 913 | } |
| 914 | set_transient( 'mwai_custom_chatbot_' . $customId, $serverParams, 60 * 60 * 24 ); |
| 915 | } |
| 916 | |
| 917 | // Retrieve the actions, shortcuts, and blocks we want to inject at the beginning |
| 918 | $filterParams = [ |
| 919 | 'step' => 'init', |
| 920 | 'botId' => $botId, |
| 921 | 'params' => array_merge( $frontParams, $frontSystem, $serverParams ) |
| 922 | ]; |
| 923 | $actions = apply_filters( 'mwai_chatbot_actions', [], $filterParams ); |
| 924 | $blocks = apply_filters( 'mwai_chatbot_blocks', [], $filterParams ); |
| 925 | $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams ); |
| 926 | $frontSystem['actions'] = $this->sanitize_actions( $actions ); |
| 927 | $frontSystem['blocks'] = $this->sanitize_blocks( $blocks ); |
| 928 | $frontSystem['shortcuts'] = $this->sanitize_shortcuts( $shortcuts ); |
| 929 | |
| 930 | // Client-side: Prepare JSON for Front Params and System Params |
| 931 | $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null; |
| 932 | $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' ); |
| 933 | $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' ); |
| 934 | $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' ); |
| 935 | //$jsonAttributes = htmlspecialchars(json_encode($atts), ENT_QUOTES, 'UTF-8'); |
| 936 | |
| 937 | $this->enqueue_scripts( $frontParams['themeId'] ?? null ); |
| 938 | |
| 939 | return "<div class='mwai-chatbot-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>"; |
| 940 | } |
| 941 | |
| 942 | public function chatbot_discussions( $atts ) { |
| 943 | $atts = empty( $atts ) ? [] : $atts; |
| 944 | |
| 945 | // Resolve the bot info |
| 946 | $resolvedBot = $this->resolveBotInfo( $atts ); |
| 947 | if ( isset( $resolvedBot['error'] ) ) { |
| 948 | return $resolvedBot['error']; |
| 949 | } |
| 950 | $chatbot = $resolvedBot['chatbot']; |
| 951 | $botId = $resolvedBot['botId']; |
| 952 | $customId = $resolvedBot['customId']; |
| 953 | |
| 954 | // Rename the keys of the atts into camelCase to match the internal params system. |
| 955 | $atts = array_map( function ( $key, $value ) { |
| 956 | $key = str_replace( '_', ' ', $key ); |
| 957 | $key = ucwords( $key ); |
| 958 | $key = str_replace( ' ', '', $key ); |
| 959 | $key = lcfirst( $key ); |
| 960 | return [ $key => $value ]; |
| 961 | }, array_keys( $atts ), $atts ); |
| 962 | $atts = array_merge( ...$atts ); |
| 963 | |
| 964 | // Front Params |
| 965 | $frontParams = []; |
| 966 | // All discussion params are text params that need sanitization |
| 967 | $textParams = ['textNewChat']; |
| 968 | |
| 969 | foreach ( MWAI_DISCUSSIONS_FRONT_PARAMS as $param ) { |
| 970 | if ( isset( $atts[$param] ) ) { |
| 971 | // Sanitize text parameters |
| 972 | $frontParams[$param] = in_array( $param, $textParams ) ? sanitize_text_field( $atts[$param] ) : $atts[$param]; |
| 973 | } |
| 974 | else if ( isset( $chatbot[$param] ) ) { |
| 975 | $frontParams[$param] = $chatbot[$param]; |
| 976 | } |
| 977 | } |
| 978 | |
| 979 | // Server Params |
| 980 | $serverParams = []; |
| 981 | foreach ( MWAI_DISCUSSIONS_SERVER_PARAMS as $param ) { |
| 982 | if ( isset( $atts[$param] ) ) { |
| 983 | $serverParams[$param] = $atts[$param]; |
| 984 | } |
| 985 | } |
| 986 | |
| 987 | // Front System |
| 988 | $frontSystem = $this->build_front_params( $botId, $customId ); |
| 989 | // Get refresh interval from settings |
| 990 | $refresh_interval = $this->core->get_option( 'chatbot_discussions_refresh_interval' ); |
| 991 | if ( $refresh_interval === 'Never' ) { |
| 992 | $frontSystem['refreshInterval'] = 0; |
| 993 | } |
| 994 | elseif ( $refresh_interval === 'Manual' ) { |
| 995 | $frontSystem['refreshInterval'] = -1; |
| 996 | } |
| 997 | elseif ( is_numeric( $refresh_interval ) ) { |
| 998 | $frontSystem['refreshInterval'] = intval( $refresh_interval ) * 1000; // Convert to milliseconds |
| 999 | } |
| 1000 | else { |
| 1001 | $frontSystem['refreshInterval'] = 5000; // Default to 5 seconds |
| 1002 | } |
| 1003 | $frontSystem['refreshInterval'] = apply_filters( 'mwai_discussions_refresh_interval', $frontSystem['refreshInterval'] ); |
| 1004 | |
| 1005 | // Get paging setting |
| 1006 | $paging_option = $this->core->get_option( 'chatbot_discussions_paging' ); |
| 1007 | if ( $paging_option === 'None' ) { |
| 1008 | $frontSystem['paging'] = 0; // No pagination |
| 1009 | } |
| 1010 | else { |
| 1011 | $frontSystem['paging'] = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Default to 10 |
| 1012 | } |
| 1013 | |
| 1014 | // Clean Params |
| 1015 | $frontParams = $this->clean_params( $frontParams ); |
| 1016 | $frontSystem = $this->clean_params( $frontSystem ); |
| 1017 | $serverParams = $this->clean_params( $serverParams ); |
| 1018 | |
| 1019 | $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null; |
| 1020 | $jsonFrontParams = htmlspecialchars( json_encode( $frontParams ), ENT_QUOTES, 'UTF-8' ); |
| 1021 | $jsonFrontSystem = htmlspecialchars( json_encode( $frontSystem ), ENT_QUOTES, 'UTF-8' ); |
| 1022 | $jsonFrontTheme = htmlspecialchars( json_encode( $theme ), ENT_QUOTES, 'UTF-8' ); |
| 1023 | |
| 1024 | return "<div class='mwai-discussions-container' data-params='{$jsonFrontParams}' data-system='{$jsonFrontSystem}' data-theme='{$jsonFrontTheme}'></div>"; |
| 1025 | } |
| 1026 | |
| 1027 | public function clean_params( &$params ) { |
| 1028 | foreach ( $params as $param => $value ) { |
| 1029 | if ( $param === 'restNonce' ) { |
| 1030 | continue; |
| 1031 | } |
| 1032 | if ( empty( $value ) || is_array( $value ) ) { |
| 1033 | continue; |
| 1034 | } |
| 1035 | $lowerCaseValue = strtolower( $value ); |
| 1036 | if ( $lowerCaseValue === 'true' || $lowerCaseValue === 'false' || is_bool( $value ) ) { |
| 1037 | $params[$param] = filter_var( $value, FILTER_VALIDATE_BOOLEAN ); |
| 1038 | } |
| 1039 | else if ( is_numeric( $value ) ) { |
| 1040 | $params[$param] = filter_var( $value, FILTER_VALIDATE_FLOAT ); |
| 1041 | } |
| 1042 | } |
| 1043 | return $params; |
| 1044 | } |
| 1045 | |
| 1046 | } |
| 1047 |