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 +153 -39 2.2.1 → 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
@@ -125,10 +122,11 @@
125 122 0, self::MAX_SELECTED_CONVERSATIONS
126 123 )
127 124 : null;
128 125 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
126 + $webSearch = filter_var($request->get('web_search', false), FILTER_VALIDATE_BOOLEAN);
127 + $temperature = max(0, min(2, floatval($request->get('temperature', 0))));
129 128
130 - // Cap and sanitize seed messages: only allowed roles, bounded content length, count capped.
131 129 $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES);
132 130 $seedMessages = [];
133 131 foreach ($seedMessagesRaw as $m) {
134 132 if (!is_array($m)) {
@@ -154,14 +152,11 @@
154 152 $customAI = new FluentBotService();
155 153 $ticket = Ticket::findOrFail($ticketId);
156 154 $this->ensureCanAccessTicket($ticket);
157 155
158 - // Authorization passed — safe to touch ticket meta.
159 - // If client requests a reset, clear stored chat mapping before reading.
160 - // 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.
161 157 if ($resetChat) {
162 - // Match the stricter permission gate used by deleteChatId — broader
163 - // 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.
164 159 if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets()
165 160 || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) {
166 161 throw new \Exception(__('You do not have permission to reset chat', 'fluent-support'));
167 162 }
@@ -170,9 +165,9 @@
170 165 $conversationId = '';
171 166 } else {
172 167 $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
173 168 $conversationId = $storedChat ? $storedChat->value : '';
174 - // Guard against corrupt stored values; fall back to fresh chat upstream
169 + // A corrupt stored value starts a fresh chat upstream.
175 170 if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) {
176 171 $conversationId = '';
177 172 }
178 173 }
@@ -180,20 +175,19 @@
180 175 if ($type === 'modifyResponse') {
181 176 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
182 177 return $result;
183 178 } else {
184 - // 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.
185 180 if ($selectedConversations === null) {
186 181 $ticket->load('responses');
187 182 }
188 183
189 - // Prevent WordPress and PHP from flushing/compressing buffers on shutdown
184 + // Raw SSE: no compression, no buffers, no flush on shutdown.
190 185 remove_action('shutdown', 'wp_ob_end_flush_all', 1);
191 186 @ini_set('zlib.output_compression', 'Off');
192 187 @ini_set('output_buffering', 'Off');
193 188 @ini_set('output_handler', '');
194 189
195 - // Clear all output buffers for raw SSE streaming
196 190 $maxLevels = 10;
197 191 while (ob_get_level() && $maxLevels-- > 0) {
198 192 @ob_end_clean();
199 193 }
@@ -202,17 +196,14 @@
202 196 header('Cache-Control: no-cache');
203 197 header('Connection: keep-alive');
204 198 header('X-Accel-Buffering: no');
205 199
206 - // Send initial connection event
207 200 echo "event: connected\n";
208 201 echo "data: Connection established\n\n";
209 202 flush();
210 203
211 - // Start streaming response
212 - $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null);
204 + $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature);
213 205
214 - // Send end event
215 206 echo "event: end\n";
216 207 echo "data: Stream completed\n\n";
217 208 flush();
218 209
@@ -218,11 +209,9 @@
218 209
219 210 exit;
220 211 }
221 212 } catch (\Exception $e) {
222 - // Send error as SSE event. Inline message extraction here because
223 - // Helper::getSafeErrorMessage() throws ValidationException and would
224 - // short-circuit the echo/flush/exit below.
213 + // Not Helper::getSafeErrorMessage(): it throws, and the error must go out as an SSE event.
225 214 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
226 215 echo "event: error\n";
227 216 echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
228 217 flush();
@@ -280,8 +269,49 @@
280 269 'product_id' => $productMeta ? (int) $productMeta->value : null
281 270 ];
282 271 }
283 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 +
284 314 public function getChatMessages(Request $request, $id)
285 315 {
286 316 $ticketId = intval($id);
287 317 $this->authorizeTicketAccess($ticketId);
@@ -312,10 +342,10 @@
312 342 $ticketId = intval($id);
313 343 $this->authorizeTicketAccess($ticketId);
314 344 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
315 345 $productId = $request->getSafe('product_id', 'intval', 0);
346 + $title = $request->getSafe('title', 'sanitize_text_field', '');
316 347
317 - // Validate UUID format
318 348 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
319 349 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
320 350 }
321 351
@@ -320,10 +350,9 @@
320 350 }
321 351
322 352 $db = \FluentSupport\App\App::getInstance('db');
323 353 $result = $db->transaction(function () use ($ticketId, $chatId, $productId) {
324 - // Lock the ticket row (always exists) to serialize concurrent first-writes for this ticket.
325 - // 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.
326 355 Ticket::where('id', $ticketId)->lockForUpdate()->first();
327 356
328 357 $existing = Meta::where([
329 358 'object_type' => 'ticket_meta',
@@ -330,11 +359,9 @@
330 359 'object_id' => $ticketId,
331 360 'key' => '_fluent_bot_chat_id',
332 361 ])->orderByDesc('id')->first();
333 362
334 - // Only conflict if the stored value is a valid UUID that differs from the new one.
335 - // A corrupt/legacy stored value should be overwritten — the stream endpoint already
336 - // 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.
337 364 if ($existing
338 365 && $existing->value
339 366 && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
340 367 && $existing->value !== $chatId) {
@@ -349,8 +376,10 @@
349 376 if ($result) {
350 377 return $this->sendError(['message' => $result['error']], 409);
351 378 }
352 379
380 + $this->appendChatHistory($ticketId, $chatId, $productId, $title);
381 +
353 382 return [
354 383 'success' => true,
355 384 'chat_id' => $chatId
356 385 ];
@@ -372,11 +401,10 @@
372 401 } else {
373 402 Meta::create(array_merge($where, ['value' => $value]));
374 403 }
375 404
376 - // Do not prune siblings here — concurrent first-writes could race and delete each
377 - // other's inserts, leaving zero rows. Reads use orderByDesc('id')->first() so
378 - // 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.
379 407 }
380 408
381 409 private function getTicketMeta($ticketId, $key)
382 410 {
@@ -414,8 +442,94 @@
414 442 'success' => true
415 443 ];
416 444 }
417 445
446 + /**
447 + * Past FluentBot conversations, newest first, plus the active chat_id.
448 + */
449 + public function getConversations(Request $request, $id)
450 + {
451 + $ticketId = intval($id);
452 + $this->authorizeTicketAccess($ticketId);
453 +
454 + $active = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
455 +
456 + return [
457 + 'conversations' => $this->readChatHistory($ticketId),
458 + 'active_chat_id' => $active ? $active->value : null,
459 + ];
460 + }
461 +
462 + /**
463 + * The stream endpoint trusts the stored chat_id, so only ids from this ticket's
464 + * own history may be activated.
465 + */
466 + public function switchConversation(Request $request, $id)
467 + {
468 + $ticketId = intval($id);
469 + $this->authorizeTicketAccess($ticketId);
470 + $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
471 +
472 + if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
473 + return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
474 + }
475 +
476 + $entry = null;
477 + foreach ($this->readChatHistory($ticketId) as $h) {
478 + if (isset($h['chat_id']) && $h['chat_id'] === $chatId) {
479 + $entry = $h;
480 + break;
481 + }
482 + }
483 +
484 + if (!$entry) {
485 + return $this->sendError(['message' => __('Conversation not found for this ticket.', 'fluent-support')], 404);
486 + }
487 +
488 + $productId = (int) ($entry['product_id'] ?? 0);
489 + $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
490 + $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
491 +
492 + return [
493 + 'success' => true,
494 + 'chat_id' => $chatId,
495 + 'product_id' => $productId,
496 + ];
497 + }
498 +
499 + private function readChatHistory($ticketId): array
500 + {
501 + $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_history');
502 + $history = ($meta && $meta->value) ? json_decode($meta->value, true) : [];
503 +
504 + return is_array($history) ? array_values($history) : [];
505 + }
506 +
507 + /**
508 + * Idempotent per chat_id. Capped at 20 entries.
509 + */
510 + private function appendChatHistory($ticketId, $chatId, $productId, $title = '')
511 + {
512 + $history = $this->readChatHistory($ticketId);
513 +
514 + foreach ($history as $entry) {
515 + if (isset($entry['chat_id']) && $entry['chat_id'] === $chatId) {
516 + return;
517 + }
518 + }
519 +
520 + array_unshift($history, [
521 + 'chat_id' => $chatId,
522 + 'product_id' => (int) $productId,
523 + 'title' => $title !== '' ? $title : __('Conversation', 'fluent-support'),
524 + 'created_at' => time(),
525 + ]);
526 +
527 + $history = array_slice($history, 0, 20);
528 +
529 + $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_history', wp_json_encode($history));
530 + }
531 +
418 532 public function getContextSelection(Request $request, $id)
419 533 {
420 534 $ticketId = intval($id);
421 535 $this->authorizeTicketAccess($ticketId);
@@ -436,9 +550,8 @@
436 550 $selectedIds = (array) $request->get('selected_ids', []);
437 551 $knownIds = (array) $request->get('known_ids', []);
438 552 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
439 553
440 - // Cap array sizes to prevent meta-row bloat from malicious input
441 554 $data = [
442 555 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
443 556 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
444 557 'includeTicketContent' => $includeTicketContent,
@@ -469,13 +582,14 @@
469 582 {
470 583 $ticketId = intval($id);
471 584 $this->authorizeTicketAccess($ticketId);
472 585
473 - $messageId = $request->getSafe('message_id', 'intval');
586 + // Message ids are UUIDs, not integers.
587 + $messageId = $request->getSafe('message_id', 'sanitize_text_field');
474 588 $reaction = $request->getSafe('reaction', 'sanitize_text_field');
475 589 $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
476 590
477 - if (!$messageId || $messageId < 1) {
591 + if (!$messageId || !wp_is_uuid($messageId)) {
478 592 return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422);
479 593 }
480 594
481 595 if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) {