PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.2
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
fluent-support / app / Http / Controllers / FluentBotController.php

FluentBotController.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.3.2, at app/Http/Controllers/FluentBotController.php

624 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Http\Controllers;
4
5
6 use FluentSupport\Framework\Http\Request\Request;
7 use FluentSupport\App\Http\Controllers\Controller;
8 use FluentSupport\App\Models\Ticket;
9 use FluentSupport\App\Models\Meta;
10 use FluentSupport\App\Services\Helper;
11 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotService;
12
13 class FluentBotController extends Controller
14 {
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 public function getPresetPrompts(Request $request)
21 {
22 $type = $request->getSafe('type', 'sanitize_text_field');
23
24 try {
25 return (new FluentBotService())->getPresetPrompts($type);
26 } catch (\Exception $e) {
27 return $this->sendError([
28 'message' => Helper::getSafeErrorMessage($e)
29 ]);
30 }
31 }
32
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()
37 {
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);
83 $productId = $request->getSafe('product_id', 'intval');
84 $prompt = $request->getSafe('content', 'sanitize_text_field');
85 $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
86 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
87 $type = $request->getSafe('type', 'sanitize_text_field', 'response');
88
89 try {
90 $customAI = new FluentBotService();
91
92 $ticket = Ticket::findOrFail($ticketId);
93 $this->ensureCanAccessTicket($ticket);
94
95 if ($type === 'modifyResponse') {
96 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
97 } else {
98 $ticket->load('responses');
99 $result = $customAI->generateResponse($prompt, $ticket, $productId, $conversationId ?: null);
100 }
101
102 return $result;
103 } catch (\Exception $e) {
104 return $this->sendError([
105 'message' => Helper::getSafeErrorMessage($e)
106 ]);
107 }
108 }
109
110
111
112 public function generateStreamResponse(Request $request, $id)
113 {
114 $ticketId = intval($id);
115 $productId = $request->getSafe('product_id', 'intval');
116 $prompt = $request->getSafe('content', 'sanitize_text_field');
117 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
118 $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 $webSearch = filter_var($request->get('web_search', false), FILTER_VALIDATE_BOOLEAN);
130 $temperature = max(0, min(2, floatval($request->get('temperature', 0))));
131
132 // Cap and sanitize seed messages: only allowed roles, bounded content length, count capped.
133 $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES);
134 $seedMessages = [];
135 foreach ($seedMessagesRaw as $m) {
136 if (!is_array($m)) {
137 continue;
138 }
139 $role = $m['role'] ?? '';
140 if (!in_array($role, ['visitor', 'ai'], true)) {
141 continue;
142 }
143 $content = (string) ($m['content'] ?? '');
144 if ($content === '') {
145 continue;
146 }
147 $seedMessages[] = [
148 'role' => $role,
149 'content' => mb_substr($content, 0, self::MAX_SEED_MESSAGE_LENGTH),
150 ];
151 }
152
153 $resetChat = filter_var($request->get('reset_chat', false), FILTER_VALIDATE_BOOLEAN);
154
155 try {
156 $customAI = new FluentBotService();
157 $ticket = Ticket::findOrFail($ticketId);
158 $this->ensureCanAccessTicket($ticket);
159
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.
163 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.
166 if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets()
167 || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) {
168 throw new \Exception(__('You do not have permission to reset chat', 'fluent-support'));
169 }
170 $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_id');
171 $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_product');
172 $conversationId = '';
173 } else {
174 $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
175 $conversationId = $storedChat ? $storedChat->value : '';
176 // Guard against corrupt stored values; fall back to fresh chat upstream
177 if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) {
178 $conversationId = '';
179 }
180 }
181
182 if ($type === 'modifyResponse') {
183 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
184 return $result;
185 } else {
186 // Skip eager-load when selected-context mode is active — helper fetches targeted rows
187 if ($selectedConversations === null) {
188 $ticket->load('responses');
189 }
190
191 // Prevent WordPress and PHP from flushing/compressing buffers on shutdown
192 remove_action('shutdown', 'wp_ob_end_flush_all', 1);
193 @ini_set('zlib.output_compression', 'Off');
194 @ini_set('output_buffering', 'Off');
195 @ini_set('output_handler', '');
196
197 // Clear all output buffers for raw SSE streaming
198 $maxLevels = 10;
199 while (ob_get_level() && $maxLevels-- > 0) {
200 @ob_end_clean();
201 }
202
203 header('Content-Type: text/event-stream');
204 header('Cache-Control: no-cache');
205 header('Connection: keep-alive');
206 header('X-Accel-Buffering: no');
207
208 // Send initial connection event
209 echo "event: connected\n";
210 echo "data: Connection established\n\n";
211 flush();
212
213 // Start streaming response
214 $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature);
215
216 // Send end event
217 echo "event: end\n";
218 echo "data: Stream completed\n\n";
219 flush();
220
221 exit;
222 }
223 } 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.
227 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
228 echo "event: error\n";
229 echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
230 flush();
231 exit;
232 }
233 }
234
235 public function getTicketSummary(Request $request, $id)
236 {
237 try {
238 $ticketId = intval($id);
239 $ticket = Ticket::with('responses')->findOrFail($ticketId);
240 $this->ensureCanAccessTicket($ticket);
241
242 return (new FluentBotService())->getTicketSummary($ticket);
243 } catch (\Exception $e) {
244 return $this->sendError([
245 'message' => Helper::getSafeErrorMessage($e)
246 ]);
247 }
248 }
249
250 public function getTicketTone(Request $request, $id)
251 {
252 try {
253 $ticketId = intval($id);
254 $ticket = Ticket::with('responses')->findOrFail($ticketId);
255 $this->ensureCanAccessTicket($ticket);
256
257 return (new FluentBotService())->getTicketTone($ticket);
258 } catch (\Exception $e) {
259 return $this->sendError([
260 'message' => Helper::getSafeErrorMessage($e)
261 ]);
262 }
263 }
264
265 private function authorizeTicketAccess($ticketId)
266 {
267 $ticket = Ticket::findOrFail($ticketId);
268 $this->ensureCanAccessTicket($ticket);
269 return $ticket;
270 }
271
272 public function getChatId(Request $request, $id)
273 {
274 $ticketId = intval($id);
275 $this->authorizeTicketAccess($ticketId);
276
277 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
278 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
279
280 return [
281 'chat_id' => $meta ? $meta->value : null,
282 'product_id' => $productMeta ? (int) $productMeta->value : null
283 ];
284 }
285
286 public function getChatMessages(Request $request, $id)
287 {
288 $ticketId = intval($id);
289 $this->authorizeTicketAccess($ticketId);
290 $cursor = $request->getSafe('cursor', 'sanitize_text_field', '');
291
292 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
293
294 if (!$meta || !$meta->value) {
295 return ['data' => [], 'next_cursor' => null];
296 }
297
298 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
299 $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
300
301 $result = (new FluentBotService())->getChatMessages($meta->value, $productId, $cursor ?: null);
302
303 if (is_wp_error($result)) {
304 return $this->sendError(['message' => $result->get_error_message()]);
305 }
306
307 unset($result['path'], $result['prev_cursor'], $result['prev_page_url'], $result['next_page_url']);
308
309 return $result;
310 }
311
312 public function saveChatId(Request $request, $id)
313 {
314 $ticketId = intval($id);
315 $this->authorizeTicketAccess($ticketId);
316 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
317 $productId = $request->getSafe('product_id', 'intval', 0);
318 $title = $request->getSafe('title', 'sanitize_text_field', '');
319
320 // Validate UUID format
321 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
322 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
323 }
324
325 $db = \FluentSupport\App\App::getInstance('db');
326 $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.
329 Ticket::where('id', $ticketId)->lockForUpdate()->first();
330
331 $existing = Meta::where([
332 'object_type' => 'ticket_meta',
333 'object_id' => $ticketId,
334 'key' => '_fluent_bot_chat_id',
335 ])->orderByDesc('id')->first();
336
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.
340 if ($existing
341 && $existing->value
342 && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
343 && $existing->value !== $chatId) {
344 return ['error' => __('Chat ID already set for this ticket', 'fluent-support')];
345 }
346
347 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
348 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
349 return null;
350 });
351
352 if ($result) {
353 return $this->sendError(['message' => $result['error']], 409);
354 }
355
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 $this->appendChatHistory($ticketId, $chatId, $productId, $title);
359
360 return [
361 'success' => true,
362 'chat_id' => $chatId
363 ];
364 }
365
366 private function upsertTicketMeta($ticketId, $key, $value)
367 {
368 $where = [
369 'object_type' => 'ticket_meta',
370 'object_id' => $ticketId,
371 'key' => $key,
372 ];
373
374 $meta = Meta::where($where)->orderByDesc('id')->first();
375
376 if ($meta) {
377 $meta->value = $value;
378 $meta->save();
379 } else {
380 Meta::create(array_merge($where, ['value' => $value]));
381 }
382
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.
386 }
387
388 private function getTicketMeta($ticketId, $key)
389 {
390 return Meta::where([
391 'object_type' => 'ticket_meta',
392 'object_id' => $ticketId,
393 'key' => $key,
394 ])->orderByDesc('id')->first();
395 }
396
397 private function deleteTicketMeta($ticketId, $key)
398 {
399 Meta::where([
400 'object_type' => 'ticket_meta',
401 'object_id' => $ticketId,
402 'key' => $key,
403 ])->delete();
404 }
405
406 public function deleteChatId(Request $request, $id)
407 {
408 $ticketId = intval($id);
409 $this->authorizeTicketAccess($ticketId);
410
411 Meta::where('object_type', 'ticket_meta')
412 ->where('object_id', $ticketId)
413 ->whereIn('key', [
414 '_fluent_bot_chat_id',
415 '_fluent_bot_chat_product',
416 '_fluent_bot_context_selection',
417 ])
418 ->delete();
419
420 return [
421 'success' => true
422 ];
423 }
424
425 /**
426 * List the ticket's past FluentBot conversations (newest first) plus the
427 * currently-active chat_id, for the "Past conversations" switcher.
428 */
429 public function getConversations(Request $request, $id)
430 {
431 $ticketId = intval($id);
432 $this->authorizeTicketAccess($ticketId);
433
434 $active = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
435
436 return [
437 'conversations' => $this->readChatHistory($ticketId),
438 'active_chat_id' => $active ? $active->value : null,
439 ];
440 }
441
442 /**
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.
446 */
447 public function switchConversation(Request $request, $id)
448 {
449 $ticketId = intval($id);
450 $this->authorizeTicketAccess($ticketId);
451 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
452
453 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
454 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
455 }
456
457 $entry = null;
458 foreach ($this->readChatHistory($ticketId) as $h) {
459 if (isset($h['chat_id']) && $h['chat_id'] === $chatId) {
460 $entry = $h;
461 break;
462 }
463 }
464
465 if (!$entry) {
466 return $this->sendError(['message' => __('Conversation not found for this ticket.', 'fluent-support')], 404);
467 }
468
469 $productId = (int) ($entry['product_id'] ?? 0);
470 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
471 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
472
473 return [
474 'success' => true,
475 'chat_id' => $chatId,
476 'product_id' => $productId,
477 ];
478 }
479
480 private function readChatHistory($ticketId): array
481 {
482 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_history');
483 $history = ($meta && $meta->value) ? json_decode($meta->value, true) : [];
484
485 return is_array($history) ? array_values($history) : [];
486 }
487
488 /**
489 * Prepend a conversation to the ticket's history. Idempotent per chat_id;
490 * capped so ticket meta cannot grow unbounded.
491 */
492 private function appendChatHistory($ticketId, $chatId, $productId, $title = '')
493 {
494 $history = $this->readChatHistory($ticketId);
495
496 foreach ($history as $entry) {
497 if (isset($entry['chat_id']) && $entry['chat_id'] === $chatId) {
498 return;
499 }
500 }
501
502 array_unshift($history, [
503 'chat_id' => $chatId,
504 'product_id' => (int) $productId,
505 'title' => $title !== '' ? $title : __('Conversation', 'fluent-support'),
506 'created_at' => time(),
507 ]);
508
509 $history = array_slice($history, 0, 20);
510
511 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_history', wp_json_encode($history));
512 }
513
514 public function getContextSelection(Request $request, $id)
515 {
516 $ticketId = intval($id);
517 $this->authorizeTicketAccess($ticketId);
518
519 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_context_selection');
520
521 if (!$meta) {
522 return ['data' => null];
523 }
524
525 return ['data' => Helper::safeUnserialize($meta->value)];
526 }
527
528 public function saveContextSelection(Request $request, $id)
529 {
530 $ticketId = intval($id);
531 $this->authorizeTicketAccess($ticketId);
532 $selectedIds = (array) $request->get('selected_ids', []);
533 $knownIds = (array) $request->get('known_ids', []);
534 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
535
536 // Cap array sizes to prevent meta-row bloat from malicious input
537 $data = [
538 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
539 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
540 'includeTicketContent' => $includeTicketContent,
541 ];
542
543 $serialized = maybe_serialize($data);
544
545 $this->upsertTicketMeta($ticketId, '_fluent_bot_context_selection', $serialized);
546
547 return ['success' => true];
548 }
549
550 private function resolveTicketFeedbackContext($ticketId)
551 {
552 $chatMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
553 if (!$chatMeta || !$chatMeta->value) {
554 return null;
555 }
556
557 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
558 return [
559 'chat_id' => $chatMeta->value,
560 'product_id' => $productMeta ? (int) $productMeta->value : 0,
561 ];
562 }
563
564 public function createFeedback(Request $request, $id)
565 {
566 $ticketId = intval($id);
567 $this->authorizeTicketAccess($ticketId);
568
569 // fluent-bot Message PKs are UUIDs — sanitize as text, not intval.
570 $messageId = $request->getSafe('message_id', 'sanitize_text_field');
571 $reaction = $request->getSafe('reaction', 'sanitize_text_field');
572 $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
573
574 if (!$messageId || !wp_is_uuid($messageId)) {
575 return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422);
576 }
577
578 if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) {
579 return $this->sendError(['message' => __('Invalid reaction', 'fluent-support')], 422);
580 }
581
582 $ctx = $this->resolveTicketFeedbackContext($ticketId);
583 if (!$ctx) {
584 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
585 }
586
587 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
588 $result = $helper->createFeedback($messageId, $reaction, $comment ?: null, $ctx['product_id'], $ctx['chat_id']);
589
590 if (is_wp_error($result)) {
591 return $this->sendError(['message' => $result->get_error_message()], 500);
592 }
593
594 return $result;
595 }
596
597 public function deleteFeedback(Request $request, $id, $feedback_id)
598 {
599 $ticketId = intval($id);
600 $this->authorizeTicketAccess($ticketId);
601
602 // fluent-bot Feedback PKs are integers (unlike message ids, which are UUIDs).
603 $feedbackId = intval($feedback_id);
604
605 if (!$feedbackId || $feedbackId < 1) {
606 return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422);
607 }
608
609 $ctx = $this->resolveTicketFeedbackContext($ticketId);
610 if (!$ctx) {
611 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
612 }
613
614 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
615 $result = $helper->deleteFeedback($feedbackId, $ctx['product_id'], $ctx['chat_id']);
616
617 if (is_wp_error($result)) {
618 return $this->sendError(['message' => $result->get_error_message()], 500);
619 }
620
621 return $result;
622 }
623 }
624