PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Http/Controllers/FluentBotController.php +63 -47 2.3.2 → 2.4.5 View file →
@@ -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);