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
fluent-support / app / Http / Controllers / FluentBotController.php

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

640 lines 23.1 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 // A missing type gets no presets rather than a TypeError.
23 $type = (string) $request->getSafe('type', 'sanitize_text_field', '');
24
25 try {
26 return (new FluentBotService())->getPresetPrompts($type);
27 } catch (\Exception $e) {
28 return $this->sendError([
29 'message' => Helper::getSafeErrorMessage($e)
30 ]);
31 }
32 }
33
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 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 // Configs saved before this flag existed count as enabled.
50 $generalBotEnabled = !array_key_exists('generalBotEnabled', $settings)
51 || filter_var($settings['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN);
52
53 $hasGeneralBot = $generalBotEnabled
54 && !empty($settings['generalBotId'])
55 && trim((string) $settings['generalBotId']) !== '';
56
57 $configuredProductIds = [];
58 foreach (($settings['productMappings'] ?? []) as $mapping) {
59 if (!is_array($mapping)) {
60 continue;
61 }
62 $botId = $mapping['botId'] ?? '';
63 if (trim((string) $botId) === '') {
64 continue;
65 }
66 $productId = intval($mapping['productId'] ?? 0);
67 if ($productId > 0) {
68 $configuredProductIds[] = $productId;
69 }
70 }
71
72 return [
73 'hasGeneralBot' => $hasGeneralBot,
74 'configuredProductIds' => array_values(array_unique($configuredProductIds)),
75 ];
76 }
77
78 public function generateResponse(Request $request, $id)
79 {
80 $ticketId = intval($id);
81 $productId = $request->getSafe('product_id', 'intval');
82 $prompt = $request->getSafe('content', 'sanitize_text_field');
83 $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
84 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
85 $type = $request->getSafe('type', 'sanitize_text_field', 'response');
86
87 try {
88 $customAI = new FluentBotService();
89
90 $ticket = Ticket::findOrFail($ticketId);
91 $this->ensureCanAccessTicket($ticket);
92
93 if ($type === 'modifyResponse') {
94 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
95 } else {
96 $ticket->load('responses');
97 $result = $customAI->generateResponse($prompt, $ticket, $productId, $conversationId ?: null);
98 }
99
100 return $result;
101 } catch (\Exception $e) {
102 return $this->sendError([
103 'message' => Helper::getSafeErrorMessage($e)
104 ]);
105 }
106 }
107
108
109
110 public function generateStreamResponse(Request $request, $id)
111 {
112 $ticketId = intval($id);
113 $productId = $request->getSafe('product_id', 'intval');
114 $prompt = $request->getSafe('content', 'sanitize_text_field');
115 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
116 $type = $request->getSafe('type', 'sanitize_text_field', 'response');
117 // Absent key means full ticket context. An empty list means the agent
118 // deselected every response, so stay in selected mode.
119 $selectedConversations = $request->exists('selected_conversations')
120 ? array_slice(
121 array_values(array_filter(array_map('intval', (array) $request->get('selected_conversations', [])))),
122 0, self::MAX_SELECTED_CONVERSATIONS
123 )
124 : null;
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))));
128
129 $seedMessagesRaw = array_slice((array) $request->get('conversation_history', []), -self::MAX_SEED_MESSAGES);
130 $seedMessages = [];
131 foreach ($seedMessagesRaw as $m) {
132 if (!is_array($m)) {
133 continue;
134 }
135 $role = $m['role'] ?? '';
136 if (!in_array($role, ['visitor', 'ai'], true)) {
137 continue;
138 }
139 $content = (string) ($m['content'] ?? '');
140 if ($content === '') {
141 continue;
142 }
143 $seedMessages[] = [
144 'role' => $role,
145 'content' => mb_substr($content, 0, self::MAX_SEED_MESSAGE_LENGTH),
146 ];
147 }
148
149 $resetChat = filter_var($request->get('reset_chat', false), FILTER_VALIDATE_BOOLEAN);
150
151 try {
152 $customAI = new FluentBotService();
153 $ticket = Ticket::findOrFail($ticketId);
154 $this->ensureCanAccessTicket($ticket);
155
156 // Only the ticket's stored chat_id is used, never one from the client.
157 if ($resetChat) {
158 // Same gate as deleteChatId. Read access isn't enough to clear chat state.
159 if (!(\FluentSupport\App\Modules\PermissionManager::canManageTickets()
160 || \FluentSupport\App\Modules\PermissionManager::currentUserCan('fst_draft_reply'))) {
161 throw new \Exception(__('You do not have permission to reset chat', 'fluent-support'));
162 }
163 $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_id');
164 $this->deleteTicketMeta($ticketId, '_fluent_bot_chat_product');
165 $conversationId = '';
166 } else {
167 $storedChat = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
168 $conversationId = $storedChat ? $storedChat->value : '';
169 // A corrupt stored value starts a fresh chat upstream.
170 if ($conversationId && !preg_match(self::CHAT_ID_PATTERN, $conversationId)) {
171 $conversationId = '';
172 }
173 }
174
175 if ($type === 'modifyResponse') {
176 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
177 return $result;
178 } else {
179 // In selected-context mode the service loads only the chosen rows.
180 if ($selectedConversations === null) {
181 $ticket->load('responses');
182 }
183
184 // Raw SSE: no compression, no buffers, no flush on shutdown.
185 remove_action('shutdown', 'wp_ob_end_flush_all', 1);
186 @ini_set('zlib.output_compression', 'Off');
187 @ini_set('output_buffering', 'Off');
188 @ini_set('output_handler', '');
189
190 $maxLevels = 10;
191 while (ob_get_level() && $maxLevels-- > 0) {
192 @ob_end_clean();
193 }
194
195 header('Content-Type: text/event-stream');
196 header('Cache-Control: no-cache');
197 header('Connection: keep-alive');
198 header('X-Accel-Buffering: no');
199
200 echo "event: connected\n";
201 echo "data: Connection established\n\n";
202 flush();
203
204 $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature);
205
206 echo "event: end\n";
207 echo "data: Stream completed\n\n";
208 flush();
209
210 exit;
211 }
212 } catch (\Exception $e) {
213 // Not Helper::getSafeErrorMessage(): it throws, and the error must go out as an SSE event.
214 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
215 echo "event: error\n";
216 echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
217 flush();
218 exit;
219 }
220 }
221
222 public function getTicketSummary(Request $request, $id)
223 {
224 try {
225 $ticketId = intval($id);
226 $ticket = Ticket::with('responses')->findOrFail($ticketId);
227 $this->ensureCanAccessTicket($ticket);
228
229 return (new FluentBotService())->getTicketSummary($ticket);
230 } catch (\Exception $e) {
231 return $this->sendError([
232 'message' => Helper::getSafeErrorMessage($e)
233 ]);
234 }
235 }
236
237 public function getTicketTone(Request $request, $id)
238 {
239 try {
240 $ticketId = intval($id);
241 $ticket = Ticket::with('responses')->findOrFail($ticketId);
242 $this->ensureCanAccessTicket($ticket);
243
244 return (new FluentBotService())->getTicketTone($ticket);
245 } catch (\Exception $e) {
246 return $this->sendError([
247 'message' => Helper::getSafeErrorMessage($e)
248 ]);
249 }
250 }
251
252 private function authorizeTicketAccess($ticketId)
253 {
254 $ticket = Ticket::findOrFail($ticketId);
255 $this->ensureCanAccessTicket($ticket);
256 return $ticket;
257 }
258
259 public function getChatId(Request $request, $id)
260 {
261 $ticketId = intval($id);
262 $this->authorizeTicketAccess($ticketId);
263
264 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
265 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
266
267 return [
268 'chat_id' => $meta ? $meta->value : null,
269 'product_id' => $productMeta ? (int) $productMeta->value : null
270 ];
271 }
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
314 public function getChatMessages(Request $request, $id)
315 {
316 $ticketId = intval($id);
317 $this->authorizeTicketAccess($ticketId);
318 $cursor = $request->getSafe('cursor', 'sanitize_text_field', '');
319
320 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
321
322 if (!$meta || !$meta->value) {
323 return ['data' => [], 'next_cursor' => null];
324 }
325
326 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
327 $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
328
329 $result = (new FluentBotService())->getChatMessages($meta->value, $productId, $cursor ?: null);
330
331 if (is_wp_error($result)) {
332 return $this->sendError(['message' => $result->get_error_message()]);
333 }
334
335 unset($result['path'], $result['prev_cursor'], $result['prev_page_url'], $result['next_page_url']);
336
337 return $result;
338 }
339
340 public function saveChatId(Request $request, $id)
341 {
342 $ticketId = intval($id);
343 $this->authorizeTicketAccess($ticketId);
344 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
345 $productId = $request->getSafe('product_id', 'intval', 0);
346 $title = $request->getSafe('title', 'sanitize_text_field', '');
347
348 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
349 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
350 }
351
352 $db = \FluentSupport\App\App::getInstance('db');
353 $result = $db->transaction(function () use ($ticketId, $chatId, $productId) {
354 // Serialize concurrent first-writes. The meta row may not exist yet, so lock the ticket row.
355 Ticket::where('id', $ticketId)->lockForUpdate()->first();
356
357 $existing = Meta::where([
358 'object_type' => 'ticket_meta',
359 'object_id' => $ticketId,
360 'key' => '_fluent_bot_chat_id',
361 ])->orderByDesc('id')->first();
362
363 // A corrupt stored value is overwritten. The stream endpoint already treats it as empty.
364 if ($existing
365 && $existing->value
366 && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
367 && $existing->value !== $chatId) {
368 return ['error' => __('Chat ID already set for this ticket', 'fluent-support')];
369 }
370
371 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
372 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
373 return null;
374 });
375
376 if ($result) {
377 return $this->sendError(['message' => $result['error']], 409);
378 }
379
380 $this->appendChatHistory($ticketId, $chatId, $productId, $title);
381
382 return [
383 'success' => true,
384 'chat_id' => $chatId
385 ];
386 }
387
388 private function upsertTicketMeta($ticketId, $key, $value)
389 {
390 $where = [
391 'object_type' => 'ticket_meta',
392 'object_id' => $ticketId,
393 'key' => $key,
394 ];
395
396 $meta = Meta::where($where)->orderByDesc('id')->first();
397
398 if ($meta) {
399 $meta->value = $value;
400 $meta->save();
401 } else {
402 Meta::create(array_merge($where, ['value' => $value]));
403 }
404
405 // Don't prune duplicates: concurrent writers could delete each other's rows.
406 // Reads take the newest row, so duplicates are harmless.
407 }
408
409 private function getTicketMeta($ticketId, $key)
410 {
411 return Meta::where([
412 'object_type' => 'ticket_meta',
413 'object_id' => $ticketId,
414 'key' => $key,
415 ])->orderByDesc('id')->first();
416 }
417
418 private function deleteTicketMeta($ticketId, $key)
419 {
420 Meta::where([
421 'object_type' => 'ticket_meta',
422 'object_id' => $ticketId,
423 'key' => $key,
424 ])->delete();
425 }
426
427 public function deleteChatId(Request $request, $id)
428 {
429 $ticketId = intval($id);
430 $this->authorizeTicketAccess($ticketId);
431
432 Meta::where('object_type', 'ticket_meta')
433 ->where('object_id', $ticketId)
434 ->whereIn('key', [
435 '_fluent_bot_chat_id',
436 '_fluent_bot_chat_product',
437 '_fluent_bot_context_selection',
438 ])
439 ->delete();
440
441 return [
442 'success' => true
443 ];
444 }
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
532 public function getContextSelection(Request $request, $id)
533 {
534 $ticketId = intval($id);
535 $this->authorizeTicketAccess($ticketId);
536
537 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_context_selection');
538
539 if (!$meta) {
540 return ['data' => null];
541 }
542
543 return ['data' => Helper::safeUnserialize($meta->value)];
544 }
545
546 public function saveContextSelection(Request $request, $id)
547 {
548 $ticketId = intval($id);
549 $this->authorizeTicketAccess($ticketId);
550 $selectedIds = (array) $request->get('selected_ids', []);
551 $knownIds = (array) $request->get('known_ids', []);
552 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
553
554 $data = [
555 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
556 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
557 'includeTicketContent' => $includeTicketContent,
558 ];
559
560 $serialized = maybe_serialize($data);
561
562 $this->upsertTicketMeta($ticketId, '_fluent_bot_context_selection', $serialized);
563
564 return ['success' => true];
565 }
566
567 private function resolveTicketFeedbackContext($ticketId)
568 {
569 $chatMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
570 if (!$chatMeta || !$chatMeta->value) {
571 return null;
572 }
573
574 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
575 return [
576 'chat_id' => $chatMeta->value,
577 'product_id' => $productMeta ? (int) $productMeta->value : 0,
578 ];
579 }
580
581 public function createFeedback(Request $request, $id)
582 {
583 $ticketId = intval($id);
584 $this->authorizeTicketAccess($ticketId);
585
586 // Message ids are UUIDs, not integers.
587 $messageId = $request->getSafe('message_id', 'sanitize_text_field');
588 $reaction = $request->getSafe('reaction', 'sanitize_text_field');
589 $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
590
591 if (!$messageId || !wp_is_uuid($messageId)) {
592 return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422);
593 }
594
595 if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) {
596 return $this->sendError(['message' => __('Invalid reaction', 'fluent-support')], 422);
597 }
598
599 $ctx = $this->resolveTicketFeedbackContext($ticketId);
600 if (!$ctx) {
601 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
602 }
603
604 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
605 $result = $helper->createFeedback($messageId, $reaction, $comment ?: null, $ctx['product_id'], $ctx['chat_id']);
606
607 if (is_wp_error($result)) {
608 return $this->sendError(['message' => $result->get_error_message()], 500);
609 }
610
611 return $result;
612 }
613
614 public function deleteFeedback(Request $request, $id, $feedback_id)
615 {
616 $ticketId = intval($id);
617 $this->authorizeTicketAccess($ticketId);
618
619 $feedbackId = intval($feedback_id);
620
621 if (!$feedbackId || $feedbackId < 1) {
622 return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422);
623 }
624
625 $ctx = $this->resolveTicketFeedbackContext($ticketId);
626 if (!$ctx) {
627 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
628 }
629
630 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
631 $result = $helper->deleteFeedback($feedbackId, $ctx['product_id'], $ctx['chat_id']);
632
633 if (is_wp_error($result)) {
634 return $this->sendError(['message' => $result->get_error_message()], 500);
635 }
636
637 return $result;
638 }
639 }
640