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/Services/Integrations/FluentBot/FluentBotHelper.php +354 -32 1.10.0 → 2.4.5 View file →
@@ -1,30 +1,64 @@
1 1 <?php
2 2 namespace FluentSupport\App\Services\Integrations\FluentBot;
3 3
4 4 use FluentSupport\App\Models\Meta;
5 +use FluentSupport\App\Models\Conversation;
5 6 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotAPI;
6 7 use FluentSupport\Framework\Support\Arr;
8 +use FluentSupport\App\Services\Helper;
7 9 use WP_Error;
8 10 class FluentBotHelper
9 11 {
10 - const BASE_URL = 'https://beta.fluentbot.ai/ai';
12 + const BASE_URL = 'https://dash.fluentbot.ai/api';
11 13
12 14 const ENDPOINTS = [
13 15 'default' => '/responses',
14 - 'ticket_reply' => '/chat/fs-completion',
16 + 'ticket_reply' => '/chat/fs',
15 17 ];
16 18
17 - public function generateStreamResponse($prompt, $ticket, $productId, $conversationId = null)
19 + /**
20 + * Resolve the FluentBot API base URL. Defaults to production; overridable
21 + * for local/staging via the `FLUENTBOT_API_BASE_URL` constant (wp-config)
22 + * or the `fluent_support/fluentbot_api_base_url` filter.
23 + */
24 + private function apiBaseUrl(): string
18 25 {
26 + if (defined('FLUENTBOT_API_BASE_URL') && FLUENTBOT_API_BASE_URL) {
27 + return rtrim((string) FLUENTBOT_API_BASE_URL, '/');
28 + }
29 +
30 + return rtrim((string) apply_filters('fluent_support/fluentbot_api_base_url', static::BASE_URL), '/');
31 + }
32 +
33 + public function generateStreamResponse($prompt, $ticket, $productId, $conversationId = null, $selectedConversations = null, $includeTicketContent = true, $seedMessages = null, $webSearch = false, $temperature = 0)
34 + {
19 35 $prompt = apply_filters('fluent_support/generate_response', $prompt, $ticket);
36 +
37 + $ticketMessages = [];
38 + if ($selectedConversations !== null) {
39 + $ticketMessages = $this->getSelectedTicketMessages($ticket, $selectedConversations, $includeTicketContent);
40 + } else {
41 + $ticketMessages = $this->getTicketMessages($ticket, $includeTicketContent);
42 + }
43 +
20 44 $payload = [
21 - 'ticket_conversation' => $this->getTicketMessages($ticket),
22 45 'source' => 'fluent_support',
23 46 'prompt' => $prompt,
24 47 'stream' => true,
25 - 'conversation_id' => $conversationId ?? null,
48 + 'chat_id' => $conversationId ?? null,
49 + 'enable_web_search' => (bool) $webSearch,
50 + 'temperature' => (float) $temperature,
26 51 ];
52 +
53 + if (!empty($ticketMessages)) {
54 + $payload['ticket_conversation'] = $ticketMessages;
55 + }
56 +
57 + if (!empty($seedMessages)) {
58 + $payload['seed_messages'] = $seedMessages;
59 + }
60 +
27 61 return $this->makeStreamAPICall($payload, $prompt, $ticket->id, 'ticket_reply', $productId);
28 62 }
29 63
30 64 public function modifyResponse($prompt, $selectedText, $ticketId)
@@ -69,8 +103,12 @@
69 103 return $this->getModifyResponsePresets();
70 104 }
71 105
72 106 if ($type === 'createResponse') {
107 + $customPresets = $this->getCustomPresets();
108 + if (!empty($customPresets)) {
109 + return $customPresets;
110 + }
73 111 return $this->getCreateResponsePresets();
74 112 }
75 113
76 114 return [];
@@ -75,11 +113,77 @@
75 113
76 114 return [];
77 115 }
78 116
117 + public function getCustomPresets(): array
118 + {
119 + $meta = Meta::where([
120 + 'object_type' => 'fluent_bot_settings',
121 + 'object_id' => 1,
122 + 'key' => '_fs_fluent_bot_presets'
123 + ])->orderByDesc('id')->first();
124 +
125 + if (!$meta) {
126 + return [];
127 + }
128 +
129 + $presets = Helper::safeUnserialize($meta->value);
130 +
131 + return is_array($presets) ? $presets : [];
132 + }
133 +
134 + public function saveCustomPresets(array $presets): array
135 + {
136 + $sanitized = [];
137 + foreach ($presets as $index => $preset) {
138 + if (empty($preset['label']) || empty($preset['description'])) {
139 + continue;
140 + }
141 + $sanitized[] = [
142 + 'label' => sanitize_text_field($preset['label']),
143 + 'text' => sanitize_text_field($preset['text'] ?? 'preset_' . $index),
144 + 'description' => sanitize_textarea_field($preset['description']),
145 + 'position' => intval($preset['position'] ?? $index),
146 + ];
147 + }
148 +
149 + usort($sanitized, function ($a, $b) {
150 + return $a['position'] - $b['position'];
151 + });
152 +
153 + $where = [
154 + 'object_type' => 'fluent_bot_settings',
155 + 'object_id' => 1,
156 + 'key' => '_fs_fluent_bot_presets'
157 + ];
158 +
159 + $existing = Meta::where($where)->orderByDesc('id')->first();
160 +
161 + if (empty($sanitized)) {
162 + if ($existing) {
163 + // Delete all rows for this key (including duplicates)
164 + Meta::where($where)->delete();
165 + }
166 + return [];
167 + }
168 +
169 + $serialized = maybe_serialize($sanitized);
170 +
171 + if ($existing) {
172 + // Update the latest row; do not prune siblings — concurrent first-writes could
173 + // race and delete each other's inserts, leaving zero rows (data loss).
174 + // Reads use orderByDesc('id')->first() so duplicates are harmless at read time.
175 + $existing->update(['value' => $serialized]);
176 + } else {
177 + Meta::create(array_merge($where, ['value' => $serialized]));
178 + }
179 +
180 + return $sanitized;
181 + }
182 +
79 183 private function makeAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null )
80 184 {
81 - $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
185 + $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
82 186
83 187 $credentials = $this->resolveApiCredentials($productId);
84 188
85 189 if (is_wp_error($credentials)) {
@@ -88,12 +192,12 @@
88 192
89 193 // Use bot_id instead of botId for the new API
90 194 $payload['bot_id'] = $credentials['botId'];
91 195
92 - $api = new FluentBotAPI($credentials['apiKey'], $apiUrl);
196 + $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
93 197 $result = $api->makeRequest($ticketId, $prompt, $payload);
94 198
95 - // For ticket_reply endpoint, return the full result with conversation_id
199 + // For ticket_reply endpoint, return the full result with chat_id
96 200 // For other endpoints, return just the content for backward compatibility
97 201 if ($type === 'ticket_reply' && is_array($result) && isset($result['content'])) {
98 202 return $result;
99 203 } elseif (is_array($result) && isset($result['content'])) {
@@ -104,13 +208,16 @@
104 208 }
105 209
106 210 private function makeStreamAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null)
107 211 {
108 - $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
212 + $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
109 213
110 214 $credentials = $this->resolveApiCredentials($productId);
111 215
112 216 if (is_wp_error($credentials)) {
217 + // Emit as an SSE `error` event (not a bare `data:` frame) so the client
218 + // shows a proper error state instead of rendering the JSON as AI text.
219 + echo "event: error\n";
113 220 echo "data: " . json_encode(['error' => $credentials->get_error_message()]) . "\n\n";
114 221 return;
115 222 }
116 223
@@ -116,12 +223,148 @@
116 223
117 224 // Use bot_id instead of botId for the new API
118 225 $payload['bot_id'] = $credentials['botId'];
119 226
120 - $api = new FluentBotAPI($credentials['apiKey'], $apiUrl);
227 + $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
121 228 $api->makeStreamRequest($ticketId, $prompt, $payload);
122 229 }
123 230
231 + public function getChatMessages($chatId, $productId = null, $cursor = null)
232 + {
233 + $credentials = $this->resolveApiCredentials($productId);
234 +
235 + if (is_wp_error($credentials)) {
236 + return $credentials;
237 + }
238 +
239 + $botId = $credentials['botId'];
240 + $url = $this->apiBaseUrl() . '/bots/' . $botId . '/chats/' . $chatId . '/messages';
241 +
242 + if ($cursor) {
243 + $url .= '?cursor=' . urlencode($cursor);
244 + }
245 +
246 + $response = wp_remote_get($url, [
247 + 'headers' => $this->requestHeaders($credentials['apiKey']),
248 + 'timeout' => 30,
249 + ]);
250 +
251 + if (is_wp_error($response)) {
252 + return $response;
253 + }
254 +
255 + $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
256 +
257 + if (wp_remote_retrieve_response_code($response) !== 200) {
258 + return new \WP_Error(
259 + 'fluent_bot_messages_error',
260 + $body['message'] ?? __('Failed to fetch chat messages.', 'fluent-support')
261 + );
262 + }
263 +
264 + return $body;
265 + }
266 +
267 + /**
268 + * Reconnect to an in-flight turn and stream its buffered SSE straight to the
269 + * browser. Writes directly to the output stream, so it returns nothing on
270 + * success; a credentials failure is emitted as an SSE `error` frame because the
271 + * caller has already sent SSE headers by this point.
272 + */
273 + public function resumeChatStream($chatId, $productId = null)
274 + {
275 + $credentials = $this->resolveApiCredentials($productId);
276 +
277 + if (is_wp_error($credentials)) {
278 + echo "event: error\n";
279 + echo "data: " . wp_json_encode(['error' => $credentials->get_error_message()]) . "\n\n";
280 + flush();
281 + return;
282 + }
283 +
284 + $url = $this->apiBaseUrl() . '/bots/' . $credentials['botId'] . '/chats/' . $chatId . '/stream';
285 +
286 + (new FluentBotAPI($url, $credentials['apiKey']))->makeResumeStreamRequest();
287 + }
288 +
289 + public function createFeedback($messageId, $reaction, $comment, $productId = null, $chatId = null)
290 + {
291 + $credentials = $this->resolveApiCredentials($productId);
292 +
293 + if (is_wp_error($credentials)) {
294 + return $credentials;
295 + }
296 +
297 + $payload = [
298 + 'bot_id' => $credentials['botId'],
299 + 'message_id' => $messageId,
300 + 'reaction' => $reaction,
301 + 'comments' => $comment,
302 + ];
303 +
304 + // Bind feedback to the ticket's chat so upstream can enforce message-to-chat ownership.
305 + if (!empty($chatId)) {
306 + $payload['chat_id'] = $chatId;
307 + }
308 +
309 + $response = wp_remote_post($this->apiBaseUrl() . '/feedbacks', [
310 + 'headers' => $this->requestHeaders($credentials['apiKey']),
311 + 'body' => wp_json_encode($payload),
312 + 'timeout' => 15,
313 + ]);
314 +
315 + if (is_wp_error($response)) {
316 + return $response;
317 + }
318 +
319 + $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
320 + $code = wp_remote_retrieve_response_code($response);
321 +
322 + if ($code >= 400) {
323 + return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to save feedback.', 'fluent-support'));
324 + }
325 +
326 + return $body;
327 + }
328 +
329 + public function deleteFeedback($feedbackId, $productId = null, $chatId = null)
330 + {
331 + $credentials = $this->resolveApiCredentials($productId);
332 +
333 + if (is_wp_error($credentials)) {
334 + return $credentials;
335 + }
336 +
337 + $payload = [
338 + 'bot_id' => $credentials['botId'],
339 + ];
340 +
341 + // Bind delete to the ticket's chat so upstream can enforce feedback-to-chat ownership.
342 + if (!empty($chatId)) {
343 + $payload['chat_id'] = $chatId;
344 + }
345 +
346 + $response = wp_remote_request($this->apiBaseUrl() . '/feedbacks/' . $feedbackId, [
347 + 'method' => 'DELETE',
348 + 'headers' => $this->requestHeaders($credentials['apiKey']),
349 + 'body' => wp_json_encode($payload),
350 + 'timeout' => 15,
351 + ]);
352 +
353 + if (is_wp_error($response)) {
354 + return $response;
355 + }
356 +
357 + $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
358 + $code = wp_remote_retrieve_response_code($response);
359 +
360 + if ($code >= 400) {
361 + return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to delete feedback.', 'fluent-support'));
362 + }
363 +
364 + return $body;
365 + }
366 +
124 367 private function resolveApiCredentials($productId)
125 368 {
126 369 $meta = Meta::where([
127 370 'object_type' => 'fluent_bot_settings',
@@ -126,20 +369,31 @@
126 369 $meta = Meta::where([
127 370 'object_type' => 'fluent_bot_settings',
128 371 'object_id' => 1,
129 372 'key' => '_fs_fluent_bot_config'
130 - ])->first();
373 + ])->orderByDesc('id')->first();
131 374
132 - $config = $meta ? unserialize($meta->value) : [];
375 + $config = $meta ? Helper::safeUnserialize($meta->value) : [];
376 + if (!is_array($config)) {
377 + $config = [];
378 + }
133 379
134 - $apiKey = $config['generalApiKey'] ?? '';
135 - $botId = $config['generalBotId'] ?? '';
380 + // Default true for backward compatibility with configs saved before this flag existed.
381 + $generalBotEnabled = !array_key_exists('generalBotEnabled', $config)
382 + || filter_var($config['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN);
136 383
384 + $generalBotId = $config['generalBotId'] ?? '';
385 + $botId = $generalBotEnabled ? $generalBotId : '';
386 + $matchedProductMapping = false;
387 +
137 388 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
138 389 foreach ($config['productMappings'] as $mapping) {
139 390 if ((int)$mapping['productId'] === (int)$productId) {
140 - $apiKey = $mapping['apiKey'] ?? $apiKey;
141 - $botId = $mapping['botId'] ?? $botId;
391 + $mappingBotId = trim((string)($mapping['botId'] ?? ''));
392 + if ($mappingBotId !== '') {
393 + $botId = $mappingBotId;
394 + $matchedProductMapping = true;
395 + }
142 396 break;
143 397 }
144 398 }
145 399 }
@@ -144,8 +398,16 @@
144 398 }
145 399 }
146 400
147 401 if (!$botId) {
402 + // Distinguish the "general bot disabled with no product mapping" case so admins
403 + // see a clear reason rather than a generic missing-credentials error.
404 + if (!$matchedProductMapping && !$generalBotEnabled) {
405 + return new \WP_Error(
406 + 'general_bot_disabled',
407 + __('General bot is disabled and no product-specific bot is configured for this product.', 'fluent-support')
408 + );
409 + }
148 410 return new \WP_Error(
149 411 'missing_bot_credentials',
150 412 __('Bot ID is not set for this product.', 'fluent-support')
151 413 );
@@ -150,20 +412,47 @@
150 412 __('Bot ID is not set for this product.', 'fluent-support')
151 413 );
152 414 }
153 415
416 + // The FluentBot API is team-scoped: a single API key authenticates every
417 + // bot in the team, so one general key covers both the general and any
418 + // product-specific bot. It is now required — the API rejects anonymous
419 + // calls — so surface a clear config error instead of a raw 401.
420 + $apiKey = trim((string)($config['generalApiKey'] ?? ''));
421 + if ($apiKey === '') {
422 + return new \WP_Error(
423 + 'missing_api_key',
424 + __('FluentBot API key is not set. Add it in the FluentBot integration settings.', 'fluent-support')
425 + );
426 + }
427 +
154 428 return [
429 + 'botId' => $botId,
155 430 'apiKey' => $apiKey,
156 - 'botId' => $botId
157 431 ];
158 432 }
159 433
160 - private function getTicketMessages($ticket): array
434 + /**
435 + * Build the outbound request headers, attaching the team API key as a
436 + * Bearer token so upstream can authenticate + team-scope the call.
437 + */
438 + private function requestHeaders(string $apiKey): array
161 439 {
440 + $headers = ['Content-Type' => 'application/json'];
441 +
442 + if ($apiKey !== '') {
443 + $headers['Authorization'] = 'Bearer ' . $apiKey;
444 + }
445 +
446 + return $headers;
447 + }
448 +
449 + private function getTicketMessages($ticket, $includeTicketContent = true): array
450 + {
162 451 $messages = [];
163 452 $ticketArray = $ticket->toArray();
164 453
165 - if (!empty($ticketArray['content'])) {
454 + if ($includeTicketContent && !empty($ticketArray['content'])) {
166 455 $messages[] = [
167 456 'role' => 'customer',
168 457 'message' => $this->cleanText($ticketArray['content']),
169 458 ];
@@ -179,8 +468,41 @@
179 468
180 469 return $messages;
181 470 }
182 471
472 + private function getSelectedTicketMessages($ticket, array $selectedIds, $includeTicketContent = true): array
473 + {
474 + $messages = [];
475 +
476 + if ($includeTicketContent && !empty($ticket->content)) {
477 + $messages[] = [
478 + 'role' => 'customer',
479 + 'message' => $this->cleanText($ticket->content),
480 + ];
481 + }
482 +
483 + $conversationIds = array_map('intval', array_filter($selectedIds, 'is_numeric'));
484 +
485 + if (!empty($conversationIds)) {
486 + $responses = Conversation::where('ticket_id', $ticket->id)
487 + ->whereIn('id', $conversationIds)
488 + ->where('conversation_type', 'response')
489 + ->with('person:id,person_type')
490 + ->orderBy('id', 'asc')
491 + ->get();
492 +
493 + foreach ($responses as $resp) {
494 + $role = ($resp->person && $resp->person->person_type === 'customer') ? 'customer' : 'support_agent';
495 + $messages[] = [
496 + 'role' => $role,
497 + 'message' => $this->cleanText($resp->content ?? ''),
498 + ];
499 + }
500 + }
501 +
502 + return $messages;
503 + }
504 +
183 505 private function getSimpleTicketMessages($ticket): array
184 506 {
185 507 $messages = [];
186 508 $ticketArray = $ticket->toArray();
@@ -186,15 +508,15 @@
186 508 $ticketArray = $ticket->toArray();
187 509
188 510 if (!empty($ticketArray['content'])) {
189 511 $messages[] = [
190 - 'role' => 'human',
512 + 'role' => 'visitor',
191 513 'message' => $this->cleanText($ticketArray['content']),
192 514 ];
193 515 }
194 516
195 517 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
196 - $role = Arr::get($response, 'person.person_type') === 'customer' ? 'human' : 'ai';
518 + $role = Arr::get($response, 'person.person_type') === 'customer' ? 'visitor' : 'ai';
197 519 $messages[] = [
198 520 'role' => $role,
199 521 'message' => $this->cleanText(Arr::get($response, 'content', '')),
200 522 ];
@@ -206,9 +528,9 @@
206 528
207 529
208 530 private function cleanText(string $text): string
209 531 {
210 - return trim(strip_tags($text));
532 + return trim(wp_strip_all_tags($text));
211 533 }
212 534
213 535 private function getModifyResponsePresets(): array
214 536 {
@@ -213,29 +535,29 @@
213 535 private function getModifyResponsePresets(): array
214 536 {
215 537 $presets = [
216 538 [
217 - 'label' => 'Improve Writing',
539 + 'label' => __('Improve Writing', 'fluent-support'),
218 540 'text' => 'shorten',
219 541 'description' => 'Use AI to refine the text by removing unnecessary words and making it more concise while retaining the original meaning and key information.'
220 542 ],
221 543 [
222 - 'label' => 'Fix Spelling & Grammar',
544 + 'label' => __('Fix Spelling & Grammar', 'fluent-support'),
223 545 'text' => 'lengthen',
224 546 'description' => 'Apply AI to correct any spelling and grammatical errors in the text, ensuring it is free of mistakes and reads professionally.'
225 547 ],
226 548 [
227 - 'label' => 'Make Shorter',
549 + 'label' => __('Make Shorter', 'fluent-support'),
228 550 'text' => 'friendly',
229 551 'description' => 'AI will modify the text to make it shorter and more casual, making it suitable for informal or friendly communication.'
230 552 ],
231 553 [
232 - 'label' => 'Make Longer',
554 + 'label' => __('Make Longer', 'fluent-support'),
233 555 'text' => 'professional',
234 556 'description' => 'Enhance the text by adding more details and using refined language to make it more formal and detailed, appropriate for professional settings.'
235 557 ],
236 558 [
237 - 'label' => 'Simplify Language',
559 + 'label' => __('Simplify Language', 'fluent-support'),
238 560 'text' => 'simplify',
239 561 'description' => 'Utilize AI to simplify complex phrases and terminology, making the text easier to read and understand for a general audience.'
240 562 ]
241 563 ];
@@ -246,29 +568,29 @@
246 568 private function getCreateResponsePresets(): array
247 569 {
248 570 $presets = [
249 571 [
250 - 'label' => 'Request More Information',
572 + 'label' => __('Request More Information', 'fluent-support'),
251 573 'text' => 'requestInfo',
252 574 'description' => 'Ask the customer to provide additional details or clarification about the issue they reported. This helps in gathering more information to resolve the issue effectively.'
253 575 ],
254 576 [
255 - 'label' => 'Acknowledge Issue',
577 + 'label' => __('Acknowledge Issue', 'fluent-support'),
256 578 'text' => 'acknowledgeIssue',
257 579 'description' => 'Confirm receipt of the customer\'s issue and reassure them that it is being investigated. This demonstrates that their concern is being taken seriously.'
258 580 ],
259 581 [
260 - 'label' => 'Provide Solution',
582 + 'label' => __('Provide Solution', 'fluent-support'),
261 583 'text' => 'provideSolution',
262 584 'description' => 'Offer a comprehensive solution or resolution to the problem described by the customer. This should address their concerns and provide actionable steps to resolve the issue.'
263 585 ],
264 586 [
265 - 'label' => 'Follow Up',
587 + 'label' => __('Follow Up', 'fluent-support'),
266 588 'text' => 'followUp',
267 589 'description' => 'Reach out to the customer after a solution has been provided to ensure that their issue has been resolved to their satisfaction. This helps in confirming the resolution and maintaining good customer relations.'
268 590 ],
269 591 [
270 - 'label' => 'Close Ticket',
592 + 'label' => __('Close Ticket', 'fluent-support'),
271 593 'text' => 'closeTicket',
272 594 'description' => 'Notify the customer that their ticket will be closed as the issue has been resolved. Ensure that all their concerns are addressed before closing the ticket.'
273 595 ]
274 596 ];