PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.2.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.2.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 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.2.0, at app/Http/Controllers/FluentBotController.php

526 lines 20.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 $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)
81 {
82 $ticketId = $request->getSafe('id', 'intval');
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)
113 {
114 $ticketId = $request->getSafe('id', 'intval');
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
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 try {
154 $customAI = new FluentBotService();
155 $ticket = Ticket::findOrFail($ticketId);
156 $this->ensureCanAccessTicket($ticket);
157
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 if ($type === 'modifyResponse') {
181 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
182 return $result;
183 } else {
184 // Skip eager-load when selected-context mode is active — helper fetches targeted rows
185 if ($selectedConversations === null) {
186 $ticket->load('responses');
187 }
188
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
196 $maxLevels = 10;
197 while (ob_get_level() && $maxLevels-- > 0) {
198 @ob_end_clean();
199 }
200
201 header('Content-Type: text/event-stream');
202 header('Cache-Control: no-cache');
203 header('Connection: keep-alive');
204 header('X-Accel-Buffering: no');
205
206 // Send initial connection event
207 echo "event: connected\n";
208 echo "data: Connection established\n\n";
209 flush();
210
211 // Start streaming response
212 $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null);
213
214 // Send end event
215 echo "event: end\n";
216 echo "data: Stream completed\n\n";
217 flush();
218
219 exit;
220 }
221 } 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');
226 echo "event: error\n";
227 echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
228 flush();
229 exit;
230 }
231 }
232
233 public function getTicketSummary(Request $request)
234 {
235 try {
236 $ticketId = $request->getSafe('id', 'intval');
237 $ticket = Ticket::with('responses')->findOrFail($ticketId);
238 $this->ensureCanAccessTicket($ticket);
239
240 return (new FluentBotService())->getTicketSummary($ticket);
241 } catch (\Exception $e) {
242 return $this->sendError([
243 'message' => Helper::getSafeErrorMessage($e)
244 ]);
245 }
246 }
247
248 public function getTicketTone(Request $request)
249 {
250 try {
251 $ticketId = $request->getSafe('id', 'intval');
252 $ticket = Ticket::with('responses')->findOrFail($ticketId);
253 $this->ensureCanAccessTicket($ticket);
254
255 return (new FluentBotService())->getTicketTone($ticket);
256 } catch (\Exception $e) {
257 return $this->sendError([
258 'message' => Helper::getSafeErrorMessage($e)
259 ]);
260 }
261 }
262
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)
271 {
272 $ticketId = $request->getSafe('id', 'intval');
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)
285 {
286 $ticketId = $request->getSafe('id', 'intval');
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)
311 {
312 $ticketId = $request->getSafe('id', 'intval');
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)
400 {
401 $ticketId = $request->getSafe('id', 'intval');
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)
419 {
420 $ticketId = $request->getSafe('id', 'intval');
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)
433 {
434 $ticketId = $request->getSafe('id', 'intval');
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)
469 {
470 $ticketId = $request->getSafe('id', 'intval');
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)
501 {
502 $ticketId = $request->getSafe('id', 'intval');
503 $this->authorizeTicketAccess($ticketId);
504
505 $feedbackId = $request->getSafe('feedback_id', 'intval');
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 }
526