getSafe('type', 'sanitize_text_field', ''); try { return (new FluentBotService())->getPresetPrompts($type); } catch (\Exception $e) { return $this->sendError([ 'message' => Helper::getSafeErrorMessage($e) ]); } } // Which products have a bot and whether a general bot exists, without bot IDs. // Open to any agent with ticket access, not just settings managers. public function getRuntimeConfig() { $meta = Meta::where([ 'object_type' => 'fluent_bot_settings', 'object_id' => 1, 'key' => '_fs_fluent_bot_config' ])->orderByDesc('id')->first(); $settings = $meta ? Helper::safeUnserialize($meta->value) : []; if (!is_array($settings)) { $settings = []; } // Configs saved before this flag existed count as enabled. $generalBotEnabled = !array_key_exists('generalBotEnabled', $settings) || filter_var($settings['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN); $hasGeneralBot = $generalBotEnabled && !empty($settings['generalBotId']) && trim((string) $settings['generalBotId']) !== ''; $configuredProductIds = []; foreach (($settings['productMappings'] ?? []) as $mapping) { if (!is_array($mapping)) { continue; } $botId = $mapping['botId'] ?? ''; if (trim((string) $botId) === '') { continue; } $productId = intval($mapping['productId'] ?? 0); if ($productId > 0) { $configuredProductIds[] = $productId; } } return [ 'hasGeneralBot' => $hasGeneralBot, 'configuredProductIds' => array_values(array_unique($configuredProductIds)), ]; } public function generateResponse(Request $request, $id) { $ticketId = intval($id); $productId = $request->getSafe('product_id', 'intval'); $prompt = $request->getSafe('content', 'sanitize_text_field'); $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', ''); $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', ''); $type = $request->getSafe('type', 'sanitize_text_field', 'response'); try { $customAI = new FluentBotService(); $ticket = Ticket::findOrFail($ticketId); $this->ensureCanAccessTicket($ticket); if ($type === 'modifyResponse') { $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId); } else { $ticket->load('responses'); $result = $customAI->generateResponse($prompt, $ticket, $productId, $conversationId ?: null); } return $result; } catch (\Exception $e) { return $this->sendError([ 'message' => Helper::getSafeErrorMessage($e) ]); } } public function generateStreamResponse(Request $request, $id) { $ticketId = intval($id); $productId = $request->getSafe('product_id', 'intval'); $prompt = $request->getSafe('content', 'sanitize_text_field'); $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', ''); $type = $request->getSafe('type', 'sanitize_text_field', 'response'); // Absent key means full ticket context. An empty list means the agent // deselected every response, so stay in selected mode. $selectedConversations = $request->exists('selected_conversations') ? array_slice( array_values(array_filter(array_map('intval', (array) $request->get('selected_conversations', [])))), 0, self::MAX_SELECTED_CONVERSATIONS ) : null; $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN); $webSearch = filter_var($request->get('web_search', false), FILTER_VALIDATE_BOOLEAN); $temperature = max(0, min(2, floatval($request->get('temperature', 0)))); $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES); $seedMessages = []; foreach ($seedMessagesRaw as $m) { if (!is_array($m)) { continue; } $role = $m['role'] ?? ''; if (!in_array($role, ['visitor', 'ai'], true)) { continue; } $content = (string) ($m['content'] ?? ''); if ($content === '') { continue; } $seedMessages[] = [ 'role' => $role, 'content' => mb_substr($content, 0, self::MAX_SEED_MESSAGE_LENGTH), ]; } $resetChat = filter_var($request->get('reset_chat', false), FILTER_VALIDATE_BOOLEAN); try { $customAI = new FluentBotService(); $ticket = Ticket::findOrFail($ticketId); $this->ensureCanAccessTicket($ticket); // Only the ticket's stored chat_id is used, never one from the client. if ($resetChat) { // Same gate as deleteChatId. Read access isn't enough to clear chat state. if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets() || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) { throw new \Exception(__('You do not have permission to reset chat', 'fluent-support')); } $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_id'); $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_product'); $conversationId = ''; } else { $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); $conversationId = $storedChat ? $storedChat->value : ''; // A corrupt stored value starts a fresh chat upstream. if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) { $conversationId = ''; } } if ($type === 'modifyResponse') { $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId); return $result; } else { // In selected-context mode the service loads only the chosen rows. if ($selectedConversations === null) { $ticket->load('responses'); } // Raw SSE: no compression, no buffers, no flush on shutdown. remove_action('shutdown', 'wp_ob_end_flush_all', 1); @ini_set('zlib.output_compression', 'Off'); @ini_set('output_buffering', 'Off'); @ini_set('output_handler', ''); $maxLevels = 10; while (ob_get_level() && $maxLevels-- > 0) { @ob_end_clean(); } header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); echo "event: connected\n"; echo "data: Connection established\n\n"; flush(); $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature); echo "event: end\n"; echo "data: Stream completed\n\n"; flush(); exit; } } catch (\Exception $e) { // Not Helper::getSafeErrorMessage(): it throws, and the error must go out as an SSE event. $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support'); echo "event: error\n"; echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n"; flush(); exit; } } public function getTicketSummary(Request $request, $id) { try { $ticketId = intval($id); $ticket = Ticket::with('responses')->findOrFail($ticketId); $this->ensureCanAccessTicket($ticket); return (new FluentBotService())->getTicketSummary($ticket); } catch (\Exception $e) { return $this->sendError([ 'message' => Helper::getSafeErrorMessage($e) ]); } } public function getTicketTone(Request $request, $id) { try { $ticketId = intval($id); $ticket = Ticket::with('responses')->findOrFail($ticketId); $this->ensureCanAccessTicket($ticket); return (new FluentBotService())->getTicketTone($ticket); } catch (\Exception $e) { return $this->sendError([ 'message' => Helper::getSafeErrorMessage($e) ]); } } private function authorizeTicketAccess($ticketId) { $ticket = Ticket::findOrFail($ticketId); $this->ensureCanAccessTicket($ticket); return $ticket; } public function getChatId(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product'); return [ 'chat_id' => $meta ? $meta->value : null, 'product_id' => $productMeta ? (int) $productMeta->value : null ]; } /** * Re-attach to a FluentBot turn still running upstream, e.g. after a page refresh. * Upstream sends `idle` when nothing is buffered. */ public function resumeChatStream(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); $chatId = $meta ? (string) $meta->value : ''; // The id goes into the upstream URL, so it must be well-formed. if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) { return $this->sendError(['message' => __('No conversation to resume.', 'fluent-support')], 404); } $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product'); $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval'); // Raw SSE: no compression, no buffers, no flush on shutdown. remove_action('shutdown', 'wp_ob_end_flush_all', 1); @ini_set('zlib.output_compression', 'Off'); @ini_set('output_buffering', 'Off'); @ini_set('output_handler', ''); $maxLevels = 10; while (ob_get_level() && $maxLevels-- > 0) { @ob_end_clean(); } header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); (new FluentBotService())->resumeChatStream($chatId, $productId); exit; } public function getChatMessages(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $cursor = $request->getSafe('cursor', 'sanitize_text_field', ''); $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); if (!$meta || !$meta->value) { return ['data' => [], 'next_cursor' => null]; } $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product'); $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval'); $result = (new FluentBotService())->getChatMessages($meta->value, $productId, $cursor ?: null); if (is_wp_error($result)) { return $this->sendError(['message' => $result->get_error_message()]); } unset($result['path'], $result['prev_cursor'], $result['prev_page_url'], $result['next_page_url']); return $result; } public function saveChatId(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $chatId = $request->getSafe('chat_id', 'sanitize_text_field'); $productId = $request->getSafe('product_id', 'intval', 0); $title = $request->getSafe('title', 'sanitize_text_field', ''); if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) { return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422); } $db = \FluentSupport\App\App::getInstance('db'); $result = $db->transaction(function () use ($ticketId, $chatId, $productId) { // Serialize concurrent first-writes. The meta row may not exist yet, so lock the ticket row. Ticket::where('id', $ticketId)->lockForUpdate()->first(); $existing = Meta::where([ 'object_type' => 'ticket_meta', 'object_id' => $ticketId, 'key' => '_fluent_bot_chat_id', ])->orderByDesc('id')->first(); // A corrupt stored value is overwritten. The stream endpoint already treats it as empty. if ($existing && $existing->value && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value) && $existing->value !== $chatId) { return ['error' => __('Chat ID already set for this ticket', 'fluent-support')]; } $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId); $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId); return null; }); if ($result) { return $this->sendError(['message' => $result['error']], 409); } $this->appendChatHistory($ticketId, $chatId, $productId, $title); return [ 'success' => true, 'chat_id' => $chatId ]; } private function upsertTicketMeta($ticketId, $key, $value) { $where = [ 'object_type' => 'ticket_meta', 'object_id' => $ticketId, 'key' => $key, ]; $meta = Meta::where($where)->orderByDesc('id')->first(); if ($meta) { $meta->value = $value; $meta->save(); } else { Meta::create(array_merge($where, ['value' => $value])); } // Don't prune duplicates: concurrent writers could delete each other's rows. // Reads take the newest row, so duplicates are harmless. } private function getTicketMeta($ticketId, $key) { return Meta::where([ 'object_type' => 'ticket_meta', 'object_id' => $ticketId, 'key' => $key, ])->orderByDesc('id')->first(); } private function deleteTicketMeta($ticketId, $key) { Meta::where([ 'object_type' => 'ticket_meta', 'object_id' => $ticketId, 'key' => $key, ])->delete(); } public function deleteChatId(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); Meta::where('object_type', 'ticket_meta') ->where('object_id', $ticketId) ->whereIn('key', [ '_fluent_bot_chat_id', '_fluent_bot_chat_product', '_fluent_bot_context_selection', ]) ->delete(); return [ 'success' => true ]; } /** * Past FluentBot conversations, newest first, plus the active chat_id. */ public function getConversations(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $active = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); return [ 'conversations' => $this->readChatHistory($ticketId), 'active_chat_id' => $active ? $active->value : null, ]; } /** * The stream endpoint trusts the stored chat_id, so only ids from this ticket's * own history may be activated. */ public function switchConversation(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $chatId = $request->getSafe('chat_id', 'sanitize_text_field'); if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) { return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422); } $entry = null; foreach ($this->readChatHistory($ticketId) as $h) { if (isset($h['chat_id']) && $h['chat_id'] === $chatId) { $entry = $h; break; } } if (!$entry) { return $this->sendError(['message' => __('Conversation not found for this ticket.', 'fluent-support')], 404); } $productId = (int) ($entry['product_id'] ?? 0); $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId); $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId); return [ 'success' => true, 'chat_id' => $chatId, 'product_id' => $productId, ]; } private function readChatHistory($ticketId): array { $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_history'); $history = ($meta && $meta->value) ? json_decode($meta->value, true) : []; return is_array($history) ? array_values($history) : []; } /** * Idempotent per chat_id. Capped at 20 entries. */ private function appendChatHistory($ticketId, $chatId, $productId, $title = '') { $history = $this->readChatHistory($ticketId); foreach ($history as $entry) { if (isset($entry['chat_id']) && $entry['chat_id'] === $chatId) { return; } } array_unshift($history, [ 'chat_id' => $chatId, 'product_id' => (int) $productId, 'title' => $title !== '' ? $title : __('Conversation', 'fluent-support'), 'created_at' => time(), ]); $history = array_slice($history, 0, 20); $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_history', wp_json_encode($history)); } public function getContextSelection(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $meta = $this->getTicketMeta($ticketId, '_fluent_bot_context_selection'); if (!$meta) { return ['data' => null]; } return ['data' => Helper::safeUnserialize($meta->value)]; } public function saveContextSelection(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $selectedIds = (array) $request->get('selected_ids', []); $knownIds = (array) $request->get('known_ids', []); $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN); $data = [ 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500), 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500), 'includeTicketContent' => $includeTicketContent, ]; $serialized = maybe_serialize($data); $this->upsertTicketMeta($ticketId, '_fluent_bot_context_selection', $serialized); return ['success' => true]; } private function resolveTicketFeedbackContext($ticketId) { $chatMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id'); if (!$chatMeta || !$chatMeta->value) { return null; } $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product'); return [ 'chat_id' => $chatMeta->value, 'product_id' => $productMeta ? (int) $productMeta->value : 0, ]; } public function createFeedback(Request $request, $id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); // Message ids are UUIDs, not integers. $messageId = $request->getSafe('message_id', 'sanitize_text_field'); $reaction = $request->getSafe('reaction', 'sanitize_text_field'); $comment = $request->getSafe('comments', 'sanitize_textarea_field', ''); if (!$messageId || !wp_is_uuid($messageId)) { return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422); } if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) { return $this->sendError(['message' => __('Invalid reaction', 'fluent-support')], 422); } $ctx = $this->resolveTicketFeedbackContext($ticketId); if (!$ctx) { return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422); } $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper(); $result = $helper->createFeedback($messageId, $reaction, $comment ?: null, $ctx['product_id'], $ctx['chat_id']); if (is_wp_error($result)) { return $this->sendError(['message' => $result->get_error_message()], 500); } return $result; } public function deleteFeedback(Request $request, $id, $feedback_id) { $ticketId = intval($id); $this->authorizeTicketAccess($ticketId); $feedbackId = intval($feedback_id); if (!$feedbackId || $feedbackId < 1) { return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422); } $ctx = $this->resolveTicketFeedbackContext($ticketId); if (!$ctx) { return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422); } $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper(); $result = $helper->deleteFeedback($feedbackId, $ctx['product_id'], $ctx['chat_id']); if (is_wp_error($result)) { return $this->sendError(['message' => $result->get_error_message()], 500); } return $result; } }