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 +26 -57 2.4.0 → 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();
@@ -283,14 +270,10 @@
283 270 ];
284 271 }
285 272
286 273 /**
287 - * Reconnect to the ticket's in-flight FluentBot turn and stream its buffered SSE
288 - * to the browser. Used after a refresh or a conversation switch so a turn that
289 - * is still running upstream renders live instead of appearing as an empty panel.
290 - *
291 - * Emits `idle` (via the upstream) when nothing is buffered, so the client can
292 - * stop without special-casing.
274 + * Re-attach to a FluentBot turn still running upstream, e.g. after a page refresh.
275 + * Upstream sends `idle` when nothing is buffered.
293 276 */
294 277 public function resumeChatStream(Request $request, $id)
295 278 {
296 279 $ticketId = intval($id);
@@ -298,10 +281,9 @@
298 281
299 282 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
300 283 $chatId = $meta ? (string) $meta->value : '';
301 284
302 - // Only ever resume a chat this ticket owns, and only a well-formed id —
303 - // the id is interpolated into the upstream URL.
285 + // The id goes into the upstream URL, so it must be well-formed.
304 286 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
305 287 return $this->sendError(['message' => __('No conversation to resume.', 'fluent-support')], 404);
306 288 }
307 289
@@ -307,15 +289,14 @@
307 289
308 290 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
309 291 $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
310 292
311 - // Prevent WordPress and PHP from flushing/compressing buffers on shutdown
293 + // Raw SSE: no compression, no buffers, no flush on shutdown.
312 294 remove_action('shutdown', 'wp_ob_end_flush_all', 1);
313 295 @ini_set('zlib.output_compression', 'Off');
314 296 @ini_set('output_buffering', 'Off');
315 297 @ini_set('output_handler', '');
316 298
317 - // Clear all output buffers for raw SSE streaming
318 299 $maxLevels = 10;
319 300 while (ob_get_level() && $maxLevels-- > 0) {
320 301 @ob_end_clean();
321 302 }
@@ -363,9 +344,8 @@
363 344 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
364 345 $productId = $request->getSafe('product_id', 'intval', 0);
365 346 $title = $request->getSafe('title', 'sanitize_text_field', '');
366 347
367 - // Validate UUID format
368 348 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
369 349 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
370 350 }
371 351
@@ -370,10 +350,9 @@
370 350 }
371 351
372 352 $db = \FluentSupport\App\App::getInstance('db');
373 353 $result = $db->transaction(function () use ($ticketId, $chatId, $productId) {
374 - // Lock the ticket row (always exists) to serialize concurrent first-writes for this ticket.
375 - // 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.
376 355 Ticket::where('id', $ticketId)->lockForUpdate()->first();
377 356
378 357 $existing = Meta::where([
379 358 'object_type' => 'ticket_meta',
@@ -380,11 +359,9 @@
380 359 'object_id' => $ticketId,
381 360 'key' => '_fluent_bot_chat_id',
382 361 ])->orderByDesc('id')->first();
383 362
384 - // Only conflict if the stored value is a valid UUID that differs from the new one.
385 - // A corrupt/legacy stored value should be overwritten — the stream endpoint already
386 - // 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.
387 364 if ($existing
388 365 && $existing->value
389 366 && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
390 367 && $existing->value !== $chatId) {
@@ -399,10 +376,8 @@
399 376 if ($result) {
400 377 return $this->sendError(['message' => $result['error']], 409);
401 378 }
402 379
403 - // Track the conversation so it appears in the ticket's "Past conversations"
404 - // list. Idempotent per chat_id — repeated saves of the same id are no-ops.
405 380 $this->appendChatHistory($ticketId, $chatId, $productId, $title);
406 381
407 382 return [
408 383 'success' => true,
@@ -426,11 +401,10 @@
426 401 } else {
427 402 Meta::create(array_merge($where, ['value' => $value]));
428 403 }
429 404
430 - // Do not prune siblings here — concurrent first-writes could race and delete each
431 - // other's inserts, leaving zero rows. Reads use orderByDesc('id')->first() so
432 - // 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.
433 407 }
434 408
435 409 private function getTicketMeta($ticketId, $key)
436 410 {
@@ -469,10 +443,9 @@
469 443 ];
470 444 }
471 445
472 446 /**
473 - * List the ticket's past FluentBot conversations (newest first) plus the
474 - * currently-active chat_id, for the "Past conversations" switcher.
447 + * Past FluentBot conversations, newest first, plus the active chat_id.
475 448 */
476 449 public function getConversations(Request $request, $id)
477 450 {
478 451 $ticketId = intval($id);
@@ -486,11 +459,10 @@
486 459 ];
487 460 }
488 461
489 462 /**
490 - * Make a past conversation the active one so the next message continues it.
491 - * Only a chat_id already recorded in THIS ticket's history may be selected —
492 - * 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.
493 465 */
494 466 public function switchConversation(Request $request, $id)
495 467 {
496 468 $ticketId = intval($id);
@@ -532,10 +504,9 @@
532 504 return is_array($history) ? array_values($history) : [];
533 505 }
534 506
535 507 /**
536 - * Prepend a conversation to the ticket's history. Idempotent per chat_id;
537 - * capped so ticket meta cannot grow unbounded.
508 + * Idempotent per chat_id. Capped at 20 entries.
538 509 */
539 510 private function appendChatHistory($ticketId, $chatId, $productId, $title = '')
540 511 {
541 512 $history = $this->readChatHistory($ticketId);
@@ -579,9 +550,8 @@
579 550 $selectedIds = (array) $request->get('selected_ids', []);
580 551 $knownIds = (array) $request->get('known_ids', []);
581 552 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
582 553
583 - // Cap array sizes to prevent meta-row bloat from malicious input
584 554 $data = [
585 555 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
586 556 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
587 557 'includeTicketContent' => $includeTicketContent,
@@ -612,9 +582,9 @@
612 582 {
613 583 $ticketId = intval($id);
614 584 $this->authorizeTicketAccess($ticketId);
615 585
616 - // fluent-bot Message PKs are UUIDs — sanitize as text, not intval.
586 + // Message ids are UUIDs, not integers.
617 587 $messageId = $request->getSafe('message_id', 'sanitize_text_field');
618 588 $reaction = $request->getSafe('reaction', 'sanitize_text_field');
619 589 $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
620 590
@@ -645,9 +615,8 @@
645 615 {
646 616 $ticketId = intval($id);
647 617 $this->authorizeTicketAccess($ticketId);
648 618
649 - // fluent-bot Feedback PKs are integers (unlike message ids, which are UUIDs).
650 619 $feedbackId = intval($feedback_id);
651 620
652 621 if (!$feedbackId || $feedbackId < 1) {
653 622 return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422);