PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.1.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.1.1
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 +13 -337 2.4.02.1.1 View file →
@@ -1,9 +1,8 @@
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;
6 5 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotAPI;
7 6 use FluentSupport\Framework\Support\Arr;
8 7 use FluentSupport\App\Services\Helper;
9 8 use WP_Error;
@@ -15,50 +14,18 @@
15 14 'default' => '/responses',
16 15 'ticket_reply' => '/chat/fs',
17 16 ];
18 17
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 + public function generateStreamResponse($prompt, $ticket, $productId, $conversationId = null)
25 19 {
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 - {
35 20 $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 -
44 21 $payload = [
22 + 'ticket_conversation' => $this->getTicketMessages($ticket),
45 23 'source' => 'fluent_support',
46 24 'prompt' => $prompt,
47 25 'stream' => true,
48 26 'chat_id' => $conversationId ?? null,
49 - 'enable_web_search' => (bool) $webSearch,
50 - 'temperature' => (float) $temperature,
51 27 ];
52 -
53 - if (!empty($ticketMessages)) {
54 - $payload['ticket_conversation'] = $ticketMessages;
55 - }
56 -
57 - if (!empty($seedMessages)) {
58 - $payload['seed_messages'] = $seedMessages;
59 - }
60 -
61 28 return $this->makeStreamAPICall($payload, $prompt, $ticket->id, 'ticket_reply', $productId);
62 29 }
63 30
64 31 public function modifyResponse($prompt, $selectedText, $ticketId)
@@ -103,12 +70,8 @@
103 70 return $this->getModifyResponsePresets();
104 71 }
105 72
106 73 if ($type === 'createResponse') {
107 - $customPresets = $this->getCustomPresets();
108 - if (!empty($customPresets)) {
109 - return $customPresets;
110 - }
111 74 return $this->getCreateResponsePresets();
112 75 }
113 76
114 77 return [];
@@ -113,77 +76,11 @@
113 76
114 77 return [];
115 78 }
116 79
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 -
183 80 private function makeAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null )
184 81 {
185 - $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
82 + $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
186 83
187 84 $credentials = $this->resolveApiCredentials($productId);
188 85
189 86 if (is_wp_error($credentials)) {
@@ -192,9 +89,9 @@
192 89
193 90 // Use bot_id instead of botId for the new API
194 91 $payload['bot_id'] = $credentials['botId'];
195 92
196 - $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
93 + $api = new FluentBotAPI($apiUrl);
197 94 $result = $api->makeRequest($ticketId, $prompt, $payload);
198 95
199 96 // For ticket_reply endpoint, return the full result with chat_id
200 97 // For other endpoints, return just the content for backward compatibility
@@ -208,16 +105,13 @@
208 105 }
209 106
210 107 private function makeStreamAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null)
211 108 {
212 - $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
109 + $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
213 110
214 111 $credentials = $this->resolveApiCredentials($productId);
215 112
216 113 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";
220 114 echo "data: " . json_encode(['error' => $credentials->get_error_message()]) . "\n\n";
221 115 return;
222 116 }
223 117
@@ -223,148 +117,12 @@
223 117
224 118 // Use bot_id instead of botId for the new API
225 119 $payload['bot_id'] = $credentials['botId'];
226 120
227 - $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
121 + $api = new FluentBotAPI($apiUrl);
228 122 $api->makeStreamRequest($ticketId, $prompt, $payload);
229 123 }
230 124
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 -
367 125 private function resolveApiCredentials($productId)
368 126 {
369 127 $meta = Meta::where([
370 128 'object_type' => 'fluent_bot_settings',
@@ -369,31 +127,18 @@
369 127 $meta = Meta::where([
370 128 'object_type' => 'fluent_bot_settings',
371 129 'object_id' => 1,
372 130 'key' => '_fs_fluent_bot_config'
373 - ])->orderByDesc('id')->first();
131 + ])->first();
374 132
375 133 $config = $meta ? Helper::safeUnserialize($meta->value) : [];
376 - if (!is_array($config)) {
377 - $config = [];
378 - }
379 134
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);
135 + $botId = $config['generalBotId'] ?? '';
383 136
384 - $generalBotId = $config['generalBotId'] ?? '';
385 - $botId = $generalBotEnabled ? $generalBotId : '';
386 - $matchedProductMapping = false;
387 -
388 137 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
389 138 foreach ($config['productMappings'] as $mapping) {
390 139 if ((int)$mapping['productId'] === (int)$productId) {
391 - $mappingBotId = trim((string)($mapping['botId'] ?? ''));
392 - if ($mappingBotId !== '') {
393 - $botId = $mappingBotId;
394 - $matchedProductMapping = true;
395 - }
140 + $botId = $mapping['botId'] ?? $botId;
396 141 break;
397 142 }
398 143 }
399 144 }
@@ -398,16 +143,8 @@
398 143 }
399 144 }
400 145
401 146 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 - }
410 147 return new \WP_Error(
411 148 'missing_bot_credentials',
412 149 __('Bot ID is not set for this product.', 'fluent-support')
413 150 );
@@ -412,47 +149,19 @@
412 149 __('Bot ID is not set for this product.', 'fluent-support')
413 150 );
414 151 }
415 152
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 -
428 153 return [
429 - 'botId' => $botId,
430 - 'apiKey' => $apiKey,
154 + 'botId' => $botId
431 155 ];
432 156 }
433 157
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
158 + private function getTicketMessages($ticket): array
439 159 {
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 - {
451 160 $messages = [];
452 161 $ticketArray = $ticket->toArray();
453 162
454 - if ($includeTicketContent && !empty($ticketArray['content'])) {
163 + if (!empty($ticketArray['content'])) {
455 164 $messages[] = [
456 165 'role' => 'customer',
457 166 'message' => $this->cleanText($ticketArray['content']),
458 167 ];
@@ -468,41 +177,8 @@
468 177
469 178 return $messages;
470 179 }
471 180
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 -
505 181 private function getSimpleTicketMessages($ticket): array
506 182 {
507 183 $messages = [];
508 184 $ticketArray = $ticket->toArray();
@@ -528,9 +204,9 @@
528 204
529 205
530 206 private function cleanText(string $text): string
531 207 {
532 - return trim(wp_strip_all_tags($text));
208 + return trim(strip_tags($text));
533 209 }
534 210
535 211 private function getModifyResponsePresets(): array
536 212 {