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

671 lines 25.6 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 /**
287 * Reconnect to the ticket's in-flight FluentBot turn and stream its buffered SSE
288 * to the browser. Used after a refresh or a conversation switch so a turn that
289 * is still running upstream renders live instead of appearing as an empty panel.
290 *
291 * Emits `idle` (via the upstream) when nothing is buffered, so the client can
292 * stop without special-casing.
293 */
294 public function resumeChatStream(Request $request, $id)
295 {
296 $ticketId = intval($id);
297 $this->authorizeTicketAccess($ticketId);
298
299 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
300 $chatId = $meta ? (string) $meta->value : '';
301
302 // Only ever resume a chat this ticket owns, and only a well-formed id —
303 // the id is interpolated into the upstream URL.
304 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
305 return $this->sendError(['message' => __('No conversation to resume.', 'fluent-support')], 404);
306 }
307
308 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
309 $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
310
311 // Prevent WordPress and PHP from flushing/compressing buffers on shutdown
312 remove_action('shutdown', 'wp_ob_end_flush_all', 1);
313 @ini_set('zlib.output_compression', 'Off');
314 @ini_set('output_buffering', 'Off');
315 @ini_set('output_handler', '');
316
317 // Clear all output buffers for raw SSE streaming
318 $maxLevels = 10;
319 while (ob_get_level() && $maxLevels-- > 0) {
320 @ob_end_clean();
321 }
322
323 header('Content-Type: text/event-stream');
324 header('Cache-Control: no-cache');
325 header('Connection: keep-alive');
326 header('X-Accel-Buffering: no');
327
328 (new FluentBotService())->resumeChatStream($chatId, $productId);
329
330 exit;
331 }
332
333 public function getChatMessages(Request $request, $id)
334 {
335 $ticketId = intval($id);
336 $this->authorizeTicketAccess($ticketId);
337 $cursor = $request->getSafe('cursor', 'sanitize_text_field', '');
338
339 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
340
341 if (!$meta || !$meta->value) {
342 return ['data' => [], 'next_cursor' => null];
343 }
344
345 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
346 $productId = $productMeta ? (int) $productMeta->value : $request->getSafe('product_id', 'intval');
347
348 $result = (new FluentBotService())->getChatMessages($meta->value, $productId, $cursor ?: null);
349
350 if (is_wp_error($result)) {
351 return $this->sendError(['message' => $result->get_error_message()]);
352 }
353
354 unset($result['path'], $result['prev_cursor'], $result['prev_page_url'], $result['next_page_url']);
355
356 return $result;
357 }
358
359 public function saveChatId(Request $request, $id)
360 {
361 $ticketId = intval($id);
362 $this->authorizeTicketAccess($ticketId);
363 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
364 $productId = $request->getSafe('product_id', 'intval', 0);
365 $title = $request->getSafe('title', 'sanitize_text_field', '');
366
367 // Validate UUID format
368 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
369 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
370 }
371
372 $db = \FluentSupport\App\App::getInstance('db');
373 $result = $db->transaction(function () use ($ticketId, $chatId, $productId) {
374 // Lock the ticket row (always exists) to serialize concurrent first-writes for this ticket.
375 // lockForUpdate on a non-existent meta row is a no-op, so we anchor to the parent row instead.
376 Ticket::where('id', $ticketId)->lockForUpdate()->first();
377
378 $existing = Meta::where([
379 'object_type' => 'ticket_meta',
380 'object_id' => $ticketId,
381 'key' => '_fluent_bot_chat_id',
382 ])->orderByDesc('id')->first();
383
384 // Only conflict if the stored value is a valid UUID that differs from the new one.
385 // A corrupt/legacy stored value should be overwritten — the stream endpoint already
386 // treats it as empty, so keeping the 409 would trap tickets in a broken state.
387 if ($existing
388 && $existing->value
389 && preg_match(self::CHAT_ID_PATTERN, (string) $existing->value)
390 && $existing->value !== $chatId) {
391 return ['error' => __('Chat ID already set for this ticket', 'fluent-support')];
392 }
393
394 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
395 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
396 return null;
397 });
398
399 if ($result) {
400 return $this->sendError(['message' => $result['error']], 409);
401 }
402
403 // Track the conversation so it appears in the ticket's "Past conversations"
404 // list. Idempotent per chat_id — repeated saves of the same id are no-ops.
405 $this->appendChatHistory($ticketId, $chatId, $productId, $title);
406
407 return [
408 'success' => true,
409 'chat_id' => $chatId
410 ];
411 }
412
413 private function upsertTicketMeta($ticketId, $key, $value)
414 {
415 $where = [
416 'object_type' => 'ticket_meta',
417 'object_id' => $ticketId,
418 'key' => $key,
419 ];
420
421 $meta = Meta::where($where)->orderByDesc('id')->first();
422
423 if ($meta) {
424 $meta->value = $value;
425 $meta->save();
426 } else {
427 Meta::create(array_merge($where, ['value' => $value]));
428 }
429
430 // Do not prune siblings here — concurrent first-writes could race and delete each
431 // other's inserts, leaving zero rows. Reads use orderByDesc('id')->first() so
432 // duplicates are harmless at read time. saveChatId() serializes via ticket-row lock.
433 }
434
435 private function getTicketMeta($ticketId, $key)
436 {
437 return Meta::where([
438 'object_type' => 'ticket_meta',
439 'object_id' => $ticketId,
440 'key' => $key,
441 ])->orderByDesc('id')->first();
442 }
443
444 private function deleteTicketMeta($ticketId, $key)
445 {
446 Meta::where([
447 'object_type' => 'ticket_meta',
448 'object_id' => $ticketId,
449 'key' => $key,
450 ])->delete();
451 }
452
453 public function deleteChatId(Request $request, $id)
454 {
455 $ticketId = intval($id);
456 $this->authorizeTicketAccess($ticketId);
457
458 Meta::where('object_type', 'ticket_meta')
459 ->where('object_id', $ticketId)
460 ->whereIn('key', [
461 '_fluent_bot_chat_id',
462 '_fluent_bot_chat_product',
463 '_fluent_bot_context_selection',
464 ])
465 ->delete();
466
467 return [
468 'success' => true
469 ];
470 }
471
472 /**
473 * List the ticket's past FluentBot conversations (newest first) plus the
474 * currently-active chat_id, for the "Past conversations" switcher.
475 */
476 public function getConversations(Request $request, $id)
477 {
478 $ticketId = intval($id);
479 $this->authorizeTicketAccess($ticketId);
480
481 $active = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
482
483 return [
484 'conversations' => $this->readChatHistory($ticketId),
485 'active_chat_id' => $active ? $active->value : null,
486 ];
487 }
488
489 /**
490 * Make a past conversation the active one so the next message continues it.
491 * Only a chat_id already recorded in THIS ticket's history may be selected —
492 * the stream endpoint trusts the stored chat_id, so this is the ownership gate.
493 */
494 public function switchConversation(Request $request, $id)
495 {
496 $ticketId = intval($id);
497 $this->authorizeTicketAccess($ticketId);
498 $chatId = $request->getSafe('chat_id', 'sanitize_text_field');
499
500 if (!$chatId || !preg_match(self::CHAT_ID_PATTERN, $chatId)) {
501 return $this->sendError(['message' => __('Invalid chat_id format', 'fluent-support')], 422);
502 }
503
504 $entry = null;
505 foreach ($this->readChatHistory($ticketId) as $h) {
506 if (isset($h['chat_id']) && $h['chat_id'] === $chatId) {
507 $entry = $h;
508 break;
509 }
510 }
511
512 if (!$entry) {
513 return $this->sendError(['message' => __('Conversation not found for this ticket.', 'fluent-support')], 404);
514 }
515
516 $productId = (int) ($entry['product_id'] ?? 0);
517 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_id', $chatId);
518 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_product', $productId);
519
520 return [
521 'success' => true,
522 'chat_id' => $chatId,
523 'product_id' => $productId,
524 ];
525 }
526
527 private function readChatHistory($ticketId): array
528 {
529 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_history');
530 $history = ($meta && $meta->value) ? json_decode($meta->value, true) : [];
531
532 return is_array($history) ? array_values($history) : [];
533 }
534
535 /**
536 * Prepend a conversation to the ticket's history. Idempotent per chat_id;
537 * capped so ticket meta cannot grow unbounded.
538 */
539 private function appendChatHistory($ticketId, $chatId, $productId, $title = '')
540 {
541 $history = $this->readChatHistory($ticketId);
542
543 foreach ($history as $entry) {
544 if (isset($entry['chat_id']) && $entry['chat_id'] === $chatId) {
545 return;
546 }
547 }
548
549 array_unshift($history, [
550 'chat_id' => $chatId,
551 'product_id' => (int) $productId,
552 'title' => $title !== '' ? $title : __('Conversation', 'fluent-support'),
553 'created_at' => time(),
554 ]);
555
556 $history = array_slice($history, 0, 20);
557
558 $this->upsertTicketMeta($ticketId, '_fluent_bot_chat_history', wp_json_encode($history));
559 }
560
561 public function getContextSelection(Request $request, $id)
562 {
563 $ticketId = intval($id);
564 $this->authorizeTicketAccess($ticketId);
565
566 $meta = $this->getTicketMeta($ticketId, '_fluent_bot_context_selection');
567
568 if (!$meta) {
569 return ['data' => null];
570 }
571
572 return ['data' => Helper::safeUnserialize($meta->value)];
573 }
574
575 public function saveContextSelection(Request $request, $id)
576 {
577 $ticketId = intval($id);
578 $this->authorizeTicketAccess($ticketId);
579 $selectedIds = (array) $request->get('selected_ids', []);
580 $knownIds = (array) $request->get('known_ids', []);
581 $includeTicketContent = filter_var($request->get('include_ticket_content', true), FILTER_VALIDATE_BOOLEAN);
582
583 // Cap array sizes to prevent meta-row bloat from malicious input
584 $data = [
585 'selectedIds' => array_slice(array_map('intval', $selectedIds), 0, 500),
586 'knownIds' => array_slice(array_map('intval', $knownIds), 0, 500),
587 'includeTicketContent' => $includeTicketContent,
588 ];
589
590 $serialized = maybe_serialize($data);
591
592 $this->upsertTicketMeta($ticketId, '_fluent_bot_context_selection', $serialized);
593
594 return ['success' => true];
595 }
596
597 private function resolveTicketFeedbackContext($ticketId)
598 {
599 $chatMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_id');
600 if (!$chatMeta || !$chatMeta->value) {
601 return null;
602 }
603
604 $productMeta = $this->getTicketMeta($ticketId, '_fluent_bot_chat_product');
605 return [
606 'chat_id' => $chatMeta->value,
607 'product_id' => $productMeta ? (int) $productMeta->value : 0,
608 ];
609 }
610
611 public function createFeedback(Request $request, $id)
612 {
613 $ticketId = intval($id);
614 $this->authorizeTicketAccess($ticketId);
615
616 // fluent-bot Message PKs are UUIDs — sanitize as text, not intval.
617 $messageId = $request->getSafe('message_id', 'sanitize_text_field');
618 $reaction = $request->getSafe('reaction', 'sanitize_text_field');
619 $comment = $request->getSafe('comments', 'sanitize_textarea_field', '');
620
621 if (!$messageId || !wp_is_uuid($messageId)) {
622 return $this->sendError(['message' => __('Invalid message_id', 'fluent-support')], 422);
623 }
624
625 if (!$reaction || !in_array($reaction, ['positive', 'negative'], true)) {
626 return $this->sendError(['message' => __('Invalid reaction', 'fluent-support')], 422);
627 }
628
629 $ctx = $this->resolveTicketFeedbackContext($ticketId);
630 if (!$ctx) {
631 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
632 }
633
634 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
635 $result = $helper->createFeedback($messageId, $reaction, $comment ?: null, $ctx['product_id'], $ctx['chat_id']);
636
637 if (is_wp_error($result)) {
638 return $this->sendError(['message' => $result->get_error_message()], 500);
639 }
640
641 return $result;
642 }
643
644 public function deleteFeedback(Request $request, $id, $feedback_id)
645 {
646 $ticketId = intval($id);
647 $this->authorizeTicketAccess($ticketId);
648
649 // fluent-bot Feedback PKs are integers (unlike message ids, which are UUIDs).
650 $feedbackId = intval($feedback_id);
651
652 if (!$feedbackId || $feedbackId < 1) {
653 return $this->sendError(['message' => __('Invalid feedback_id', 'fluent-support')], 422);
654 }
655
656 $ctx = $this->resolveTicketFeedbackContext($ticketId);
657 if (!$ctx) {
658 return $this->sendError(['message' => __('No active chat for this ticket', 'fluent-support')], 422);
659 }
660
661 $helper = new \FluentSupport\App\Services\Integrations\FluentBot\FluentBotHelper();
662 $result = $helper->deleteFeedback($feedbackId, $ctx['product_id'], $ctx['chat_id']);
663
664 if (is_wp_error($result)) {
665 return $this->sendError(['message' => $result->get_error_message()], 500);
666 }
667
668 return $result;
669 }
670 }
671