| @@ -18,9 +18,10 @@ | ||
| 18 | 18 | private const MAX_SEED_MESSAGE_LENGTH = 5000; |
| 19 | 19 | |
| 20 | 20 | public function getPresetPrompts(Request $request) |
| 21 | 21 | { |
| 22 | - $type = $request->getSafe('type', 'sanitize_text_field'); | |
| 22 | + // A missing type gets no presets rather than a TypeError. | |
| 23 | + $type = (string) $request->getSafe('type', 'sanitize_text_field', ''); | |
| 23 | 24 | |
| 24 | 25 | try { |
| 25 | 26 | return (new FluentBotService())->getPresetPrompts($type); |
| 26 | 27 | } catch (\Exception $e) { |
| @@ -29,11 +30,10 @@ | ||
| 29 | 30 | ]); |
| 30 | 31 | } |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | - // Ticket-safe runtime config for the chat panel: tells the UI which products have a bot | |
| 34 | - // configured and whether a general bot exists. Never exposes bot IDs. Readable by any | |
| 35 | - // agent with ticket-route access so the panel works without AdminSettingsPolicy. | |
| 34 | + // Which products have a bot and whether a general bot exists, without bot IDs. | |
| 35 | + // Open to any agent with ticket access, not just settings managers. | |
| 36 | 36 | public function getRuntimeConfig() |
| 37 | 37 | { |
| 38 | 38 | $meta = Meta::where([ |
| 39 | 39 | 'object_type' => 'fluent_bot_settings', |
| @@ -45,11 +45,9 @@ | ||
| 45 | 45 | if (!is_array($settings)) { |
| 46 | 46 | $settings = []; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | - // generalBotEnabled defaults to true for backward compat with configs saved | |
| 50 | - // before the flag existed. The runtime-config consumer uses this to decide | |
| 51 | - // whether to show the "General Bot" option in the ticket-side product dropdown. | |
| 49 | + // Configs saved before this flag existed count as enabled. | |
| 52 | 50 | $generalBotEnabled = !array_key_exists('generalBotEnabled', $settings) |
| 53 | 51 | || filter_var($settings['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN); |
| 54 | 52 | |
| 55 | 53 | $hasGeneralBot = $generalBotEnabled |
| @@ -115,11 +113,10 @@ | ||
| 115 | 113 | $productId = $request->getSafe('product_id', 'intval'); |
| 116 | 114 | $prompt = $request->getSafe('content', 'sanitize_text_field'); |
| 117 | 115 | $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', ''); |
| 118 | 116 | $type = $request->getSafe('type', 'sanitize_text_field', 'response'); |
| 119 | - // Distinguish "key absent" (use full ticket context) from "explicit empty list" | |
| 120 | - // (user intentionally deselected all responses — keep selected mode). | |
| 121 | - // Normalize to non-empty int IDs and cap length to prevent abuse. | |
| 117 | + // Absent key means full ticket context. An empty list means the agent | |
| 118 | + // deselected every response, so stay in selected mode. | |
| 122 | 119 | $selectedConversations = $request->exists('selected_conversations') |
| 123 | 120 | ? array_slice( |
| 124 | 121 | array_values(array_filter(array_map('intval', (array) $request->get('selected_conversations', [])))), |
| 125 | 122 | 0, self::MAX_SELECTED_CONVERSATIONS |
| @@ -128,9 +125,8 @@ | ||
| 128 | 125 | $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN); |
| 129 | 126 | $webSearch = filter_var($request->get('web_search', false), FILTER_VALIDATE_BOOLEAN); |
| 130 | 127 | $temperature = max(0, min(2, floatval($request->get('temperature', 0)))); |
| 131 | 128 | |
| 132 | - // Cap and sanitize seed messages: only allowed roles, bounded content length, count capped. | |
| 133 | 129 | $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES); |
| 134 | 130 | $seedMessages = []; |
| 135 | 131 | foreach ($seedMessagesRaw as $m) { |
| 136 | 132 | if (!is_array($m)) { |
| @@ -156,14 +152,11 @@ | ||
| 156 | 152 | $customAI = new FluentBotService(); |
| 157 | 153 | $ticket = Ticket::findOrFail($ticketId); |
| 158 | 154 | $this->ensureCanAccessTicket($ticket); |
| 159 | 155 | |
| 160 | - // Authorization passed — safe to touch ticket meta. | |
| 161 | - // If client requests a reset, clear stored chat mapping before reading. | |
| 162 | - // Otherwise only use the ticket's stored chat_id — ignore client-supplied values. | |
| 156 | + // Only the ticket's stored chat_id is used, never one from the client. | |
| 163 | 157 | if ($resetChat) { |
| 164 | - // Match the stricter permission gate used by deleteChatId — broader | |
| 165 | - // ticket-read access should not be enough to clear persisted chat state. | |
| 158 | + // Same gate as deleteChatId. Read access isn't enough to clear chat state. | |
| 166 | 159 | if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets() |
| 167 | 160 | || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) { |
| 168 | 161 | throw new \Exception(__('You do not have permission to reset chat', 'fluent-support')); |
| 169 | 162 | } |
| @@ -172,9 +165,9 @@ | ||
| 172 | 165 | $conversationId = ''; |
| 173 | 166 | } else { |
| 174 | 167 | $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); |
| 175 | 168 | $conversationId = $storedChat ? $storedChat->value : ''; |
| 176 | - // Guard against corrupt stored values; fall back to fresh chat upstream | |
| 169 | + // A corrupt stored value starts a fresh chat upstream. | |
| 177 | 170 | if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) { |
| 178 | 171 | $conversationId = ''; |
| 179 | 172 | } |
| 180 | 173 | } |
| @@ -182,20 +175,19 @@ | ||
| 182 | 175 | if ($type === 'modifyResponse') { |
| 183 | 176 | $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId); |
| 184 | 177 | return $result; |
| 185 | 178 | } else { |
| 186 | - // Skip eager-load when selected-context mode is active — helper fetches targeted rows | |
| 179 | + // In selected-context mode the service loads only the chosen rows. | |
| 187 | 180 | if ($selectedConversations === null) { |
| 188 | 181 | $ticket->load('responses'); |
| 189 | 182 | } |
| 190 | 183 | |
| 191 | - // Prevent WordPress and PHP from flushing/compressing buffers on shutdown | |
| 184 | + // Raw SSE: no compression, no buffers, no flush on shutdown. | |
| 192 | 185 | remove_action('shutdown', 'wp_ob_end_flush_all', 1); |
| 193 | 186 | @ini_set('zlib.output_compression', 'Off'); |
| 194 | 187 | @ini_set('output_buffering', 'Off'); |
| 195 | 188 | @ini_set('output_handler', ''); |
| 196 | 189 | |
| 197 | - // Clear all output buffers for raw SSE streaming | |
| 198 | 190 | $maxLevels = 10; |
| 199 | 191 | while (ob_get_level() && $maxLevels-- > 0) { |
| 200 | 192 | @ob_end_clean(); |
| 201 | 193 | } |
| @@ -204,17 +196,14 @@ | ||
| 204 | 196 | header('Cache-Control: no-cache'); |
| 205 | 197 | header('Connection: keep-alive'); |
| 206 | 198 | header('X-Accel-Buffering: no'); |
| 207 | 199 | |
| 208 | - // Send initial connection event | |
| 209 | 200 | echo "event: connected\n"; |
| 210 | 201 | echo "data: Connection established\n\n"; |
| 211 | 202 | flush(); |
| 212 | 203 | |
| 213 | - // Start streaming response | |
| 214 | 204 | $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature); |
| 215 | 205 | |
| 216 | - // Send end event | |
| 217 | 206 | echo "event: end\n"; |
| 218 | 207 | echo "data: Stream completed\n\n"; |
| 219 | 208 | flush(); |
| 220 | 209 | |
| @@ -220,11 +209,9 @@ | ||
| 220 | 209 | |
| 221 | 210 | exit; |
| 222 | 211 | } |
| 223 | 212 | } catch (\Exception $e) { |
| 224 | - // Send error as SSE event. Inline message extraction here because | |
| 225 | - // Helper::getSafeErrorMessage() throws ValidationException and would | |
| 226 | - // short-circuit the echo/flush/exit below. | |
| 213 | + // Not Helper::getSafeErrorMessage(): it throws, and the error must go out as an SSE event. | |
| 227 | 214 | $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support'); |
| 228 | 215 | echo "event: error\n"; |
| 229 | 216 | echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n"; |
| 230 | 217 | flush(); |
| @@ -282,8 +269,49 @@ | ||
| 282 | 269 | 'product_id' => $productMeta ? (int) $productMeta->value : null |
| 283 | 270 | ]; |
| 284 | 271 | } |
| 285 | 272 | |
| 273 | + /** | |
| 274 | + * Re-attach to a FluentBot turn still running upstream, e.g. after a page refresh. | |
| 275 | + * Upstream sends `idle` when nothing is buffered. | |
| 276 | + */ | |
| 277 | + public function resumeChatStream(Request $request, $id) | |
| 278 | + { | |
| 279 | + $ticketId = intval($id); | |
| 280 | + $this->authorizeTicketAccess($ticketId); | |
| 281 | + | |
| 282 | + $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); | |
| 283 | + $chatId = $meta ? (string) $meta->value : ''; | |
| 284 | + | |
| 285 | + // The id goes into the upstream URL, so it must be well-formed. | |
| 286 | + if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) { | |
| 287 | + return $this->sendError(['message' => __('No conversation to resume.', 'fluent-support')], 404); | |
| 288 | + } | |
| 289 | + | |
| 290 | + $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product'); | |
| 291 | + $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval'); | |
| 292 | + | |
| 293 | + // Raw SSE: no compression, no buffers, no flush on shutdown. | |
| 294 | + remove_action('shutdown', 'wp_ob_end_flush_all', 1); | |
| 295 | + @ini_set('zlib.output_compression', 'Off'); | |
| 296 | + @ini_set('output_buffering', 'Off'); | |
| 297 | + @ini_set('output_handler', ''); | |
| 298 | + | |
| 299 | + $maxLevels = 10; | |
| 300 | + while (ob_get_level() && $maxLevels-- > 0) { | |
| 301 | + @ob_end_clean(); | |
| 302 | + } | |
| 303 | + | |
| 304 | + header('Content-Type: text/event-stream'); | |
| 305 | + header('Cache-Control: no-cache'); | |
| 306 | + header('Connection: keep-alive'); | |
| 307 | + header('X-Accel-Buffering: no'); | |
| 308 | + | |
| 309 | + (new FluentBotService())->resumeChatStream($chatId, $productId); | |
| 310 | + | |
| 311 | + exit; | |
| 312 | + } | |
| 313 | + | |
| 286 | 314 | public function getChatMessages(Request $request, $id) |
| 287 | 315 | { |
| 288 | 316 | $ticketId = intval($id); |
| 289 | 317 | $this->authorizeTicketAccess($ticketId); |
| @@ -316,9 +344,8 @@ | ||
| 316 | 344 | $chatId = $request->getSafe('chat_id', 'sanitize_text_field'); |
| 317 | 345 | $productId = $request->getSafe('product_id', 'intval', 0); |
| 318 | 346 | $title = $request->getSafe('title', 'sanitize_text_field', ''); |
| 319 | 347 | |
| 320 | - // Validate UUID format | |
| 321 | 348 | if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) { |
| 322 | 349 | return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422); |
| 323 | 350 | } |
| 324 | 351 | |
| @@ -323,10 +350,9 @@ | ||
| 323 | 350 | } |
| 324 | 351 | |
| 325 | 352 | $db = \FluentSupport\App\App::getInstance('db'); |
| 326 | 353 | $result = $db->transaction(function () use ($ticketId, $chatId, $productId) { |
| 327 | - // Lock the ticket row (always exists) to serialize concurrent first-writes for this ticket. | |
| 328 | - // lockForUpdate on a non-existent meta row is a no-op, so we anchor to the parent row instead. | |
| 354 | + // Serialize concurrent first-writes. The meta row may not exist yet, so lock the ticket row. | |
| 329 | 355 | Ticket::where('id', $ticketId)->lockForUpdate()->first(); |
| 330 | 356 | |
| 331 | 357 | $existing = Meta::where([ |
| 332 | 358 | 'object_type' => 'ticket_meta', |
| @@ -333,11 +359,9 @@ | ||
| 333 | 359 | 'object_id' => $ticketId, |
| 334 | 360 | 'key' => '_fluent_bot_chat_id', |
| 335 | 361 | ])->orderByDesc('id')->first(); |
| 336 | 362 | |
| 337 | - // Only conflict if the stored value is a valid UUID that differs from the new one. | |
| 338 | - // A corrupt/legacy stored value should be overwritten — the stream endpoint already | |
| 339 | - // treats it as empty, so keeping the 409 would trap tickets in a broken state. | |
| 363 | + // A corrupt stored value is overwritten. The stream endpoint already treats it as empty. | |
| 340 | 364 | if ($existing |
| 341 | 365 | && $existing->value |
| 342 | 366 | && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value) |
| 343 | 367 | && $existing->value !== $chatId) { |
| @@ -352,10 +376,8 @@ | ||
| 352 | 376 | if ($result) { |
| 353 | 377 | return $this->sendError(['message' => $result['error']], 409); |
| 354 | 378 | } |
| 355 | 379 | |
| 356 | - // Track the conversation so it appears in the ticket's "Past conversations" | |
| 357 | - // list. Idempotent per chat_id — repeated saves of the same id are no-ops. | |
| 358 | 380 | $this->appendChatHistory($ticketId, $chatId, $productId, $title); |
| 359 | 381 | |
| 360 | 382 | return [ |
| 361 | 383 | 'success' => true, |
| @@ -379,11 +401,10 @@ | ||
| 379 | 401 | } else { |
| 380 | 402 | Meta::create(array_merge($where, ['value' => $value])); |
| 381 | 403 | } |
| 382 | 404 | |
| 383 | - // Do not prune siblings here — concurrent first-writes could race and delete each | |
| 384 | - // other's inserts, leaving zero rows. Reads use orderByDesc('id')->first() so | |
| 385 | - // duplicates are harmless at read time. saveChatId() serializes via ticket-row lock. | |
| 405 | + // Don't prune duplicates: concurrent writers could delete each other's rows. | |
| 406 | + // Reads take the newest row, so duplicates are harmless. | |
| 386 | 407 | } |
| 387 | 408 | |
| 388 | 409 | private function getTicketMeta($ticketId, $key) |
| 389 | 410 | { |
| @@ -422,10 +443,9 @@ | ||
| 422 | 443 | ]; |
| 423 | 444 | } |
| 424 | 445 | |
| 425 | 446 | /** |
| 426 | - * List the ticket's past FluentBot conversations (newest first) plus the | |
| 427 | - * currently-active chat_id, for the "Past conversations" switcher. | |
| 447 | + * Past FluentBot conversations, newest first, plus the active chat_id. | |
| 428 | 448 | */ |
| 429 | 449 | public function getConversations(Request $request, $id) |
| 430 | 450 | { |
| 431 | 451 | $ticketId = intval($id); |
| @@ -439,11 +459,10 @@ | ||
| 439 | 459 | ]; |
| 440 | 460 | } |
| 441 | 461 | |
| 442 | 462 | /** |
| 443 | - * Make a past conversation the active one so the next message continues it. | |
| 444 | - * Only a chat_id already recorded in THIS ticket's history may be selected — | |
| 445 | - * the stream endpoint trusts the stored chat_id, so this is the ownership gate. | |
| 463 | + * The stream endpoint trusts the stored chat_id, so only ids from this ticket's | |
| 464 | + * own history may be activated. | |
| 446 | 465 | */ |
| 447 | 466 | public function switchConversation(Request $request, $id) |
| 448 | 467 | { |
| 449 | 468 | $ticketId = intval($id); |
| @@ -485,10 +504,9 @@ | ||
| 485 | 504 | return is_array($history) ? array_values($history) : []; |
| 486 | 505 | } |
| 487 | 506 | |
| 488 | 507 | /** |
| 489 | - * Prepend a conversation to the ticket's history. Idempotent per chat_id; | |
| 490 | - * capped so ticket meta cannot grow unbounded. | |
| 508 | + * Idempotent per chat_id. Capped at 20 entries. | |
| 491 | 509 | */ |
| 492 | 510 | private function appendChatHistory($ticketId, $chatId, $productId, $title = '') |
| 493 | 511 | { |
| 494 | 512 | $history = $this->readChatHistory($ticketId); |
| @@ -532,9 +550,8 @@ | ||
| 532 | 550 | $selectedIds = (array) $request->get('selected_ids', []); |
| 533 | 551 | $knownIds = (array) $request->get('known_ids', []); |
| 534 | 552 | $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN); |
| 535 | 553 | |
| 536 | - // Cap array sizes to prevent meta-row bloat from malicious input | |
| 537 | 554 | $data = [ |
| 538 | 555 | 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500), |
| 539 | 556 | 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500), |
| 540 | 557 | 'includeTicketContent' => $includeTicketContent, |
| @@ -565,9 +582,9 @@ | ||
| 565 | 582 | { |
| 566 | 583 | $ticketId = intval($id); |
| 567 | 584 | $this->authorizeTicketAccess($ticketId); |
| 568 | 585 | |
| 569 | - // fluent-bot Message PKs are UUIDs — sanitize as text, not intval. | |
| 586 | + // Message ids are UUIDs, not integers. | |
| 570 | 587 | $messageId = $request->getSafe('message_id', 'sanitize_text_field'); |
| 571 | 588 | $reaction = $request->getSafe('reaction', 'sanitize_text_field'); |
| 572 | 589 | $comment = $request->getSafe('comments', 'sanitize_textarea_field', ''); |
| 573 | 590 | |
| @@ -598,9 +615,8 @@ | ||
| 598 | 615 | { |
| 599 | 616 | $ticketId = intval($id); |
| 600 | 617 | $this->authorizeTicketAccess($ticketId); |
| 601 | 618 | |
| 602 | - // fluent-bot Feedback PKs are integers (unlike message ids, which are UUIDs). | |
| 603 | 619 | $feedbackId = intval($feedback_id); |
| 604 | 620 | |
| 605 | 621 | if (!$feedbackId || $feedbackId < 1) { |
| 606 | 622 | return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422); |