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/Services/Integrations/FluentBot/FluentBotHelper.php +344 -22 1.10.52.4.0 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 {