PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.1.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.1.1
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 1.5.5 1.5.6 All 67 releases
← All changes | app/Http/Controllers/FluentBotController.php +22 -399 2.2.12.1.1 View file →
@@ -5,19 +5,13 @@
5 5
6 6 use FluentSupport\Framework\Http\Request\Request;
7 7 use FluentSupport\App\Http\Controllers\Controller;
8 8 use FluentSupport\App\Models\Ticket;
9 -use FluentSupport\App\Models\Meta;
10 9 use FluentSupport\App\Services\Helper;
11 10 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotService;
12 11
13 12 class FluentBotController extends Controller
14 13 {
15 - private const CHAT_ID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
16 - private const MAX_SELECTED_CONVERSATIONS = 500;
17 - private const MAX_SEED_MESSAGES = 50;
18 - private const MAX_SEED_MESSAGE_LENGTH = 5000;
19 -
20 14 public function getPresetPrompts(Request $request)
21 15 {
22 16 $type = $request->getSafe('type', 'sanitize_text_field');
23 17
@@ -29,58 +23,11 @@
29 23 ]);
30 24 }
31 25 }
32 26
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.
36 - public function getRuntimeConfig()
27 + public function generateResponse(Request $request)
37 28 {
38 - $meta = Meta::where([
39 - 'object_type' => 'fluent_bot_settings',
40 - 'object_id' => 1,
41 - 'key' => '_fs_fluent_bot_config'
42 - ])->orderByDesc('id')->first();
43 -
44 - $settings = $meta ? Helper::safeUnserialize($meta->value) : [];
45 - if (!is_array($settings)) {
46 - $settings = [];
47 - }
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.
52 - $generalBotEnabled = !array_key_exists('generalBotEnabled', $settings)
53 - || filter_var($settings['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN);
54 -
55 - $hasGeneralBot = $generalBotEnabled
56 - && !empty($settings['generalBotId'])
57 - && trim((string) $settings['generalBotId']) !== '';
58 -
59 - $configuredProductIds = [];
60 - foreach (($settings['productMappings'] ?? []) as $mapping) {
61 - if (!is_array($mapping)) {
62 - continue;
63 - }
64 - $botId = $mapping['botId'] ?? '';
65 - if (trim((string) $botId) === '') {
66 - continue;
67 - }
68 - $productId = intval($mapping['productId'] ?? 0);
69 - if ($productId > 0) {
70 - $configuredProductIds[] = $productId;
71 - }
72 - }
73 -
74 - return [
75 - 'hasGeneralBot' => $hasGeneralBot,
76 - 'configuredProductIds' => array_values(array_unique($configuredProductIds)),
77 - ];
78 - }
79 -
80 - public function generateResponse(Request $request, $id)
81 - {
82 - $ticketId = intval($id);
29 + $ticketId = $request->getSafe('id', 'intval');
83 30 $productId = $request->getSafe('product_id', 'intval');
84 31 $prompt = $request->getSafe('content', 'sanitize_text_field');
85 32 $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
86 33 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
@@ -106,104 +53,45 @@
106 53 ]);
107 54 }
108 55 }
109 56
110 -
111 -
112 - public function generateStreamResponse(Request $request, $id)
57 + public function generateStreamResponse(Request $request)
113 58 {
114 - $ticketId = intval($id);
59 + $ticketId = $request->getSafe('id', 'intval');
115 60 $productId = $request->getSafe('product_id', 'intval');
116 61 $prompt = $request->getSafe('content', 'sanitize_text_field');
62 + $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
117 63 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
118 64 $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.
122 - $selectedConversations = $request->exists('selected_conversations')
123 - ? array_slice(
124 - array_values(array_filter(array_map('intval', (array) $request->get('selected_conversations', [])))),
125 - 0, self::MAX_SELECTED_CONVERSATIONS
126 - )
127 - : null;
128 - $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
129 65
130 - // Cap and sanitize seed messages: only allowed roles, bounded content length, count capped.
131 - $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES);
132 - $seedMessages = [];
133 - foreach ($seedMessagesRaw as $m) {
134 - if (!is_array($m)) {
135 - continue;
136 - }
137 - $role = $m['role'] ?? '';
138 - if (!in_array($role, ['visitor', 'ai'], true)) {
139 - continue;
140 - }
141 - $content = (string) ($m['content'] ?? '');
142 - if ($content === '') {
143 - continue;
144 - }
145 - $seedMessages[] = [
146 - 'role' => $role,
147 - 'content' => mb_substr($content, 0, self::MAX_SEED_MESSAGE_LENGTH),
148 - ];
149 - }
150 -
151 - $resetChat = filter_var($request->get('reset_chat', false), FILTER_VALIDATE_BOOLEAN);
152 -
153 66 try {
154 67 $customAI = new FluentBotService();
155 68 $ticket = Ticket::findOrFail($ticketId);
156 69 $this->ensureCanAccessTicket($ticket);
157 70
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.
161 - 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.
164 - if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets()
165 - || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) {
166 - throw new \Exception(__('You do not have permission to reset chat', 'fluent-support'));
167 - }
168 - $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_id');
169 - $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_product');
170 - $conversationId = '';
171 - } else {
172 - $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
173 - $conversationId = $storedChat ? $storedChat->value : '';
174 - // Guard against corrupt stored values; fall back to fresh chat upstream
175 - if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) {
176 - $conversationId = '';
177 - }
178 - }
179 -
180 71 if ($type === 'modifyResponse') {
181 72 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
182 73 return $result;
183 74 } else {
184 - // Skip eager-load when selected-context mode is active — helper fetches targeted rows
185 - if ($selectedConversations === null) {
186 - $ticket->load('responses');
187 - }
75 + $ticket->load('responses');
188 76
189 - // Prevent WordPress and PHP from flushing/compressing buffers on shutdown
190 - remove_action('shutdown', 'wp_ob_end_flush_all', 1);
191 - @ini_set('zlib.output_compression', 'Off');
192 - @ini_set('output_buffering', 'Off');
193 - @ini_set('output_handler', '');
194 -
195 - // Clear all output buffers for raw SSE streaming
77 + // Disable all output buffering (with safety limit)
196 78 $maxLevels = 10;
197 79 while (ob_get_level() && $maxLevels-- > 0) {
198 - @ob_end_clean();
80 + ob_end_clean();
199 81 }
200 82
83 + // Set headers for Server-Sent Events
201 84 header('Content-Type: text/event-stream');
202 85 header('Cache-Control: no-cache');
203 86 header('Connection: keep-alive');
204 - header('X-Accel-Buffering: no');
87 + header('X-Accel-Buffering: no'); // Disable nginx buffering
88 + header('Access-Control-Allow-Origin: *');
89 + header('Access-Control-Allow-Headers: Cache-Control');
205 90
91 + // Disable WordPress output buffering
92 + remove_action('shutdown', 'wp_ob_end_flush_all', 1);
93 +
206 94 // Send initial connection event
207 95 echo "event: connected\n";
208 96 echo "data: Connection established\n\n";
209 97 flush();
@@ -208,9 +96,9 @@
208 96 echo "data: Connection established\n\n";
209 97 flush();
210 98
211 99 // Start streaming response
212 - $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null);
100 + $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null);
213 101
214 102 // Send end event
215 103 echo "event: end\n";
216 104 echo "data: Stream completed\n\n";
@@ -218,23 +106,20 @@
218 106
219 107 exit;
220 108 }
221 109 } 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.
225 - $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
110 + // Send error as SSE event
226 111 echo "event: error\n";
227 - echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
112 + echo "data: " . json_encode(['message' => esc_html(Helper::getSafeErrorMessage($e))]) . "\n\n";
228 113 flush();
229 114 exit;
230 115 }
231 116 }
232 117
233 - public function getTicketSummary(Request $request, $id)
118 + public function getTicketSummary(Request $request)
234 119 {
235 120 try {
236 - $ticketId = intval($id);
121 + $ticketId = $request->getSafe('id', 'intval');
237 122 $ticket = Ticket::with('responses')->findOrFail($ticketId);
238 123 $this->ensureCanAccessTicket($ticket);
239 124
240 125 return (new FluentBotService())->getTicketSummary($ticket);
@@ -244,12 +129,12 @@
244 129 ]);
245 130 }
246 131 }
247 132
248 - public function getTicketTone(Request $request, $id)
133 + public function getTicketTone(Request $request)
249 134 {
250 135 try {
251 - $ticketId = intval($id);
136 + $ticketId = $request->getSafe('id', 'intval');
252 137 $ticket = Ticket::with('responses')->findOrFail($ticketId);
253 138 $this->ensureCanAccessTicket($ticket);
254 139
255 140 return (new FluentBotService())->getTicketTone($ticket);
@@ -259,267 +144,5 @@
259 144 ]);
260 145 }
261 146 }
262 147
263 - private function authorizeTicketAccess($ticketId)
264 - {
265 - $ticket = Ticket::findOrFail($ticketId);
266 - $this->ensureCanAccessTicket($ticket);
267 - return $ticket;
268 - }
269 -
270 - public function getChatId(Request $request, $id)
271 - {
272 - $ticketId = intval($id);
273 - $this->authorizeTicketAccess($ticketId);
274 -
275 - $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
276 - $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
277 -
278 - return [
279 - 'chat_id' => $meta ? $meta->value : null,
280 - 'product_id' => $productMeta ? (int) $productMeta->value : null
281 - ];
282 - }
283 -
284 - public function getChatMessages(Request $request, $id)
285 - {
286 - $ticketId = intval($id);
287 - $this->authorizeTicketAccess($ticketId);
288 - $cursor = $request->getSafe('cursor', 'sanitize_text_field', '');
289 -
290 - $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
291 -
292 - if (!$meta || !$meta->value) {
293 - return ['data' => [], 'next_cursor' => null];
294 - }
295 -
296 - $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
297 - $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
298 -
299 - $result = (new FluentBotService())->getChatMessages($meta->value, $productId, $cursor ?: null);
300 -
301 - if (is_wp_error($result)) {
302 - return $this->sendError(['message' => $result->get_error_message()]);
303 - }
304 -
305 - unset($result['path'], $result['prev_cursor'], $result['prev_page_url'], $result['next_page_url']);
306 -
307 - return $result;
308 - }
309 -
310 - public function saveChatId(Request $request, $id)
311 - {
312 - $ticketId = intval($id);
313 - $this->authorizeTicketAccess($ticketId);
314 - $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
315 - $productId = $request->getSafe('product_id', 'intval', 0);
316 -
317 - // Validate UUID format
318 - if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
319 - return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
320 - }
321 -
322 - $db = \FluentSupport\App\App::getInstance('db');
323 - $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.
326 - Ticket::where('id', $ticketId)->lockForUpdate()->first();
327 -
328 - $existing = Meta::where([
329 - 'object_type' => 'ticket_meta',
330 - 'object_id' => $ticketId,
331 - 'key' => '_fluent_bot_chat_id',
332 - ])->orderByDesc('id')->first();
333 -
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.
337 - if ($existing
338 - && $existing->value
339 - && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
340 - && $existing->value !== $chatId) {
341 - return ['error' => __('Chat ID already set for this ticket', 'fluent-support')];
342 - }
343 -
344 - $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
345 - $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
346 - return null;
347 - });
348 -
349 - if ($result) {
350 - return $this->sendError(['message' => $result['error']], 409);
351 - }
352 -
353 - return [
354 - 'success' => true,
355 - 'chat_id' => $chatId
356 - ];
357 - }
358 -
359 - private function upsertTicketMeta($ticketId, $key, $value)
360 - {
361 - $where = [
362 - 'object_type' => 'ticket_meta',
363 - 'object_id' => $ticketId,
364 - 'key' => $key,
365 - ];
366 -
367 - $meta = Meta::where($where)->orderByDesc('id')->first();
368 -
369 - if ($meta) {
370 - $meta->value = $value;
371 - $meta->save();
372 - } else {
373 - Meta::create(array_merge($where, ['value' => $value]));
374 - }
375 -
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.
379 - }
380 -
381 - private function getTicketMeta($ticketId, $key)
382 - {
383 - return Meta::where([
384 - 'object_type' => 'ticket_meta',
385 - 'object_id' => $ticketId,
386 - 'key' => $key,
387 - ])->orderByDesc('id')->first();
388 - }
389 -
390 - private function deleteTicketMeta($ticketId, $key)
391 - {
392 - Meta::where([
393 - 'object_type' => 'ticket_meta',
394 - 'object_id' => $ticketId,
395 - 'key' => $key,
396 - ])->delete();
397 - }
398 -
399 - public function deleteChatId(Request $request, $id)
400 - {
401 - $ticketId = intval($id);
402 - $this->authorizeTicketAccess($ticketId);
403 -
404 - Meta::where('object_type', 'ticket_meta')
405 - ->where('object_id', $ticketId)
406 - ->whereIn('key', [
407 - '_fluent_bot_chat_id',
408 - '_fluent_bot_chat_product',
409 - '_fluent_bot_context_selection',
410 - ])
411 - ->delete();
412 -
413 - return [
414 - 'success' => true
415 - ];
416 - }
417 -
418 - public function getContextSelection(Request $request, $id)
419 - {
420 - $ticketId = intval($id);
421 - $this->authorizeTicketAccess($ticketId);
422 -
423 - $meta = $this->getTicketMeta($ticketId, '_fluent_bot_context_selection');
424 -
425 - if (!$meta) {
426 - return ['data' => null];
427 - }
428 -
429 - return ['data' => Helper::safeUnserialize($meta->value)];
430 - }
431 -
432 - public function saveContextSelection(Request $request, $id)
433 - {
434 - $ticketId = intval($id);
435 - $this->authorizeTicketAccess($ticketId);
436 - $selectedIds = (array) $request->get('selected_ids', []);
437 - $knownIds = (array) $request->get('known_ids', []);
438 - $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
439 -
440 - // Cap array sizes to prevent meta-row bloat from malicious input
441 - $data = [
442 - 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
443 - 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
444 - 'includeTicketContent' => $includeTicketContent,
445 - ];
446 -
447 - $serialized = maybe_serialize($data);
448 -
449 - $this->upsertTicketMeta($ticketId, '_fluent_bot_context_selection', $serialized);
450 -
451 - return ['success' => true];
452 - }
453 -
454 - private function resolveTicketFeedbackContext($ticketId)
455 - {
456 - $chatMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
457 - if (!$chatMeta || !$chatMeta->value) {
458 - return null;
459 - }
460 -
461 - $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
462 - return [
463 - 'chat_id' => $chatMeta->value,
464 - 'product_id' => $productMeta ? (int) $productMeta->value : 0,
465 - ];
466 - }
467 -
468 - public function createFeedback(Request $request, $id)
469 - {
470 - $ticketId = intval($id);
471 - $this->authorizeTicketAccess($ticketId);
472 -
473 - $messageId = $request->getSafe('message_id', 'intval');
474 - $reaction = $request->getSafe('reaction', 'sanitize_text_field');
475 - $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
476 -
477 - if (!$messageId || $messageId < 1) {
478 - return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422);
479 - }
480 -
481 - if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) {
482 - return $this->sendError(['message' => __('Invalid reaction', 'fluent-support')], 422);
483 - }
484 -
485 - $ctx = $this->resolveTicketFeedbackContext($ticketId);
486 - if (!$ctx) {
487 - return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
488 - }
489 -
490 - $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
491 - $result = $helper->createFeedback($messageId, $reaction, $comment ?: null, $ctx['product_id'], $ctx['chat_id']);
492 -
493 - if (is_wp_error($result)) {
494 - return $this->sendError(['message' => $result->get_error_message()], 500);
495 - }
496 -
497 - return $result;
498 - }
499 -
500 - public function deleteFeedback(Request $request, $id, $feedback_id)
501 - {
502 - $ticketId = intval($id);
503 - $this->authorizeTicketAccess($ticketId);
504 -
505 - $feedbackId = intval($feedback_id);
506 -
507 - if (!$feedbackId || $feedbackId < 1) {
508 - return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422);
509 - }
510 -
511 - $ctx = $this->resolveTicketFeedbackContext($ticketId);
512 - if (!$ctx) {
513 - return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
514 - }
515 -
516 - $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
517 - $result = $helper->deleteFeedback($feedbackId, $ctx['product_id'], $ctx['chat_id']);
518 -
519 - if (is_wp_error($result)) {
520 - return $this->sendError(['message' => $result->get_error_message()], 500);
521 - }
522 -
523 - return $result;
524 - }
525 148 }