PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
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 1.5.5 All 68 releases
← All changes | app/Http/Controllers/FluentBotController.php +565 -34 1.10.02.4.0 View file →
@@ -2,15 +2,22 @@
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 22 $type = $request->getSafe('type', 'sanitize_text_field');
16 23
@@ -17,19 +24,66 @@
17 24 try {
18 25 return (new FluentBotService())->getPresetPrompts($type);
19 26 } catch (\Exception $e) {
20 27 return $this->sendError([
21 - 'message' => $e->getMessage()
28 + 'message' => Helper::getSafeErrorMessage($e)
22 29 ]);
23 30 }
24 31 }
25 32
26 - public function generateResponse(Request $request)
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()
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 + // 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);
29 83 $productId = $request->getSafe('product_id', 'intval');
30 84 $prompt = $request->getSafe('content', 'sanitize_text_field');
31 - $conversationId = $request->getSafe('conversation_id', 'sanitize_text_field', '');
85 + $conversationId = $request->getSafe('chat_id', 'sanitize_text_field', '');
32 86 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
33 87 $type = $request->getSafe('type', 'sanitize_text_field', 'response');
34 88
35 89 try {
@@ -34,12 +88,15 @@
34 88
35 89 try {
36 90 $customAI = new FluentBotService();
37 91
92 + $ticket = Ticket::findOrFail($ticketId);
93 + $this->ensureCanAccessTicket($ticket);
94 +
38 95 if ($type === 'modifyResponse') {
39 96 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
40 97 } else {
41 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
98 + $ticket->load('responses');
42 99 $result = $customAI->generateResponse($prompt, $ticket, $productId, $conversationId ?: null);
43 100 }
44 101
45 102 return $result;
@@ -44,47 +101,111 @@
44 101
45 102 return $result;
46 103 } catch (\Exception $e) {
47 104 return $this->sendError([
48 - 'message' => $e->getMessage()
105 + 'message' => Helper::getSafeErrorMessage($e)
49 106 ]);
50 107 }
51 108 }
52 109
53 - public function generateStreamResponse(Request $request)
110 +
111 +
112 + public function generateStreamResponse(Request $request, $id)
54 113 {
55 - $ticketId = $request->getSafe('id', 'intval');
114 + $ticketId = intval($id);
56 115 $productId = $request->getSafe('product_id', 'intval');
57 116 $prompt = $request->getSafe('content', 'sanitize_text_field');
58 - $conversationId = $request->getSafe('conversation_id', 'sanitize_text_field', '');
59 117 $selectedText = $request->getSafe('selectedText', 'sanitize_text_field', '');
60 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))));
61 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 +
62 155 try {
63 156 $customAI = new FluentBotService();
157 + $ticket = Ticket::findOrFail($ticketId);
158 + $this->ensureCanAccessTicket($ticket);
64 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 +
65 182 if ($type === 'modifyResponse') {
66 183 $result = $customAI->modifyResponse($prompt, $selectedText, $ticketId);
67 184 return $result;
68 185 } else {
69 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
186 + // Skip eager-load when selected-context mode is active — helper fetches targeted rows
187 + if ($selectedConversations === null) {
188 + $ticket->load('responses');
189 + }
70 190
71 - // Disable all output buffering
72 - while (ob_get_level()) {
73 - ob_end_clean();
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();
74 201 }
75 202
76 - // Set headers for Server-Sent Events
77 203 header('Content-Type: text/event-stream');
78 204 header('Cache-Control: no-cache');
79 205 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');
206 + header('X-Accel-Buffering: no');
83 207
84 - // Disable WordPress output buffering
85 - remove_action('shutdown', 'wp_ob_end_flush_all', 1);
86 -
87 208 // Send initial connection event
88 209 echo "event: connected\n";
89 210 echo "data: Connection established\n\n";
90 211 flush();
@@ -89,9 +210,9 @@
89 210 echo "data: Connection established\n\n";
90 211 flush();
91 212
92 213 // Start streaming response
93 - $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null);
214 + $customAI->generateStreamResponse($prompt, $ticket, $productId, $conversationId ?: null, $selectedConversations, $includeTicketContent, $seedMessages ?: null, $webSearch, $temperature);
94 215
95 216 // Send end event
96 217 echo "event: end\n";
97 218 echo "data: Stream completed\n\n";
@@ -99,41 +220,451 @@
99 220
100 221 exit;
101 222 }
102 223 } catch (\Exception $e) {
103 - // Send error as SSE event
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');
104 228 echo "event: error\n";
105 - echo "data: " . json_encode(['message' => $e->getMessage()]) . "\n\n";
229 + echo "data: " . json_encode(['message' => esc_html($message)]) . "\n\n";
106 230 flush();
107 231 exit;
108 232 }
109 233 }
110 234
111 - public function getTicketSummary(Request $request)
235 + public function getTicketSummary(Request $request, $id)
112 236 {
113 - $ticketId = $request->getSafe('id', 'intval');
114 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
237 + try {
238 + $ticketId = intval($id);
239 + $ticket = Ticket::with('responses')->findOrFail($ticketId);
240 + $this->ensureCanAccessTicket($ticket);
115 241
116 - try {
117 242 return (new FluentBotService())->getTicketSummary($ticket);
118 243 } catch (\Exception $e) {
119 244 return $this->sendError([
120 - 'message' => $e->getMessage()
245 + 'message' => Helper::getSafeErrorMessage($e)
121 246 ]);
122 247 }
123 248 }
124 249
125 - public function getTicketTone(Request $request)
250 + public function getTicketTone(Request $request, $id)
126 251 {
127 - $ticketId = $request->getSafe('id', 'intval');
128 - $ticket = Ticket::with('responses')->findOrFail($ticketId);
252 + try {
253 + $ticketId = intval($id);
254 + $ticket = Ticket::with('responses')->findOrFail($ticketId);
255 + $this->ensureCanAccessTicket($ticket);
129 256
130 - try {
131 257 return (new FluentBotService())->getTicketTone($ticket);
132 258 } catch (\Exception $e) {
133 259 return $this->sendError([
134 - 'message' => $e->getMessage()
260 + 'message' => Helper::getSafeErrorMessage($e)
135 261 ]);
136 262 }
137 263 }
138 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 + }
139 670 }