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
← All changes | app/Http/Controllers/FluentBotController.php +538 -38 1.10.4 → 2.4.5 View file →
@@ -2,34 +2,86 @@
2 2
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 5
6 -use FluentSupport\Framework\Request\Request;
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 +use FluentSupport\App\Services\Helper;
9 11 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotService;
10 12
11 13 class FluentBotController extends Controller
12 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 +
13 20 public function getPresetPrompts(Request $request)
14 21 {
15 - $type = $request->getSafe('type', 'sanitize_text_field');
22 + // A missing type gets no presets rather than a TypeError.
23 + $type = (string) $request->getSafe('type', 'sanitize_text_field', '');
16 24
17 25 try {
18 26 return (new FluentBotService())->getPresetPrompts($type);
19 27 } catch (\Exception $e) {
20 28 return $this->sendError([
21 - 'message' => $e->getMessage()
29 + 'message' => Helper::getSafeErrorMessage($e)
22 30 ]);
23 31 }
24 32 }
25 33
26 - public function generateResponse(Request $request)
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()
27 37 {
28 - $ticketId = $request->getSafe('id', 'intval');
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);
29 81 $productId = $request->getSafe('product_id', 'intval');
30 82 $prompt = $request->getSafe('content', 'sanitize_text_field');
31 - $conversationId = $request->getSafe('conversation_id', 'sanitize_text_field', '');
83 + $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
32 84 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
33 85 $type = $request->getSafe('type', 'sanitize_text_field', 'response');
34 86
35 87 try {
@@ -34,12 +86,15 @@
34 86
35 87 try {
36 88 $customAI = new FluentBotService();
37 89
90 + $ticket = Ticket::findOrFail($ticketId);
91 + $this->ensureCanAccessTicket($ticket);
92 +
38 93 if ($type === 'modifyResponse') {
39 94 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
40 95 } else {
41 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
96 + $ticket->load('responses');
42 97 $result = $customAI->generateResponse($prompt, $ticket, $productId, $conversationId ?: null);
43 98 }
44 99
45 100 return $result;
@@ -44,56 +99,111 @@
44 99
45 100 return $result;
46 101 } catch (\Exception $e) {
47 102 return $this->sendError([
48 - 'message' => $e->getMessage()
103 + 'message' => Helper::getSafeErrorMessage($e)
49 104 ]);
50 105 }
51 106 }
52 107
53 - public function generateStreamResponse(Request $request)
108 +
109 +
110 + public function generateStreamResponse(Request $request, $id)
54 111 {
55 - $ticketId = $request->getSafe('id', 'intval');
112 + $ticketId = intval($id);
56 113 $productId = $request->getSafe('product_id', 'intval');
57 114 $prompt = $request->getSafe('content', 'sanitize_text_field');
58 - $conversationId = $request->getSafe('conversation_id', 'sanitize_text_field', '');
59 115 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
60 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))));
61 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 +
62 151 try {
63 152 $customAI = new FluentBotService();
153 + $ticket = Ticket::findOrFail($ticketId);
154 + $this->ensureCanAccessTicket($ticket);
64 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 +
65 175 if ($type === 'modifyResponse') {
66 176 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
67 177 return $result;
68 178 } else {
69 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
179 + // In selected-context mode the service loads only the chosen rows.
180 + if ($selectedConversations === null) {
181 + $ticket->load('responses');
182 + }
70 183
71 - // Disable all output buffering
72 - while (ob_get_level()) {
73 - ob_end_clean();
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();
74 193 }
75 194
76 - // Set headers for Server-Sent Events
77 195 header('Content-Type: text/event-stream');
78 196 header('Cache-Control: no-cache');
79 197 header('Connection: keep-alive');
80 - header('X-Accel-Buffering: no'); // Disable nginx buffering
81 - header('Access-Control-Allow-Origin: *');
82 - header('Access-Control-Allow-Headers: Cache-Control');
198 + header('X-Accel-Buffering: no');
83 199
84 - // Disable WordPress output buffering
85 - remove_action('shutdown', 'wp_ob_end_flush_all', 1);
86 -
87 - // Send initial connection event
88 200 echo "event: connected\n";
89 201 echo "data: Connection established\n\n";
90 202 flush();
91 203
92 - // Start streaming response
93 - $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null);
204 + $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature);
94 205
95 - // Send end event
96 206 echo "event: end\n";
97 207 echo "data: Stream completed\n\n";
98 208 flush();
99 209
@@ -99,41 +209,431 @@
99 209
100 210 exit;
101 211 }
102 212 } catch (\Exception $e) {
103 - // Send error as SSE event
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');
104 215 echo "event: error\n";
105 - echo "data: " . json_encode(['message' => esc_html($e->getMessage())]) . "\n\n";
216 + echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
106 217 flush();
107 218 exit;
108 219 }
109 220 }
110 221
111 - public function getTicketSummary(Request $request)
222 + public function getTicketSummary(Request $request, $id)
112 223 {
113 - $ticketId = $request->getSafe('id', 'intval');
114 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
224 + try {
225 + $ticketId = intval($id);
226 + $ticket = Ticket::with('responses')->findOrFail($ticketId);
227 + $this->ensureCanAccessTicket($ticket);
115 228
116 - try {
117 229 return (new FluentBotService())->getTicketSummary($ticket);
118 230 } catch (\Exception $e) {
119 231 return $this->sendError([
120 - 'message' => $e->getMessage()
232 + 'message' => Helper::getSafeErrorMessage($e)
121 233 ]);
122 234 }
123 235 }
124 236
125 - public function getTicketTone(Request $request)
237 + public function getTicketTone(Request $request, $id)
126 238 {
127 - $ticketId = $request->getSafe('id', 'intval');
128 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
239 + try {
240 + $ticketId = intval($id);
241 + $ticket = Ticket::with('responses')->findOrFail($ticketId);
242 + $this->ensureCanAccessTicket($ticket);
129 243
130 - try {
131 244 return (new FluentBotService())->getTicketTone($ticket);
132 245 } catch (\Exception $e) {
133 246 return $this->sendError([
134 - 'message' => $e->getMessage()
247 + 'message' => Helper::getSafeErrorMessage($e)
135 248 ]);
136 249 }
137 250 }
138 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 + }
139 639 }