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 -315 2.3.22.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,126 +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 - public function createFeedback($messageId, $reaction, $comment, $productId = null, $chatId = null)
268 - {
269 - $credentials = $this->resolveApiCredentials($productId);
270 -
271 - if (is_wp_error($credentials)) {
272 - return $credentials;
273 - }
274 -
275 - $payload = [
276 - 'bot_id' => $credentials['botId'],
277 - 'message_id' => $messageId,
278 - 'reaction' => $reaction,
279 - 'comments' => $comment,
280 - ];
281 -
282 - // Bind feedback to the ticket's chat so upstream can enforce message-to-chat ownership.
283 - if (!empty($chatId)) {
284 - $payload['chat_id'] = $chatId;
285 - }
286 -
287 - $response = wp_remote_post($this->apiBaseUrl() . '/feedbacks', [
288 - 'headers' => $this->requestHeaders($credentials['apiKey']),
289 - 'body' => wp_json_encode($payload),
290 - 'timeout' => 15,
291 - ]);
292 -
293 - if (is_wp_error($response)) {
294 - return $response;
295 - }
296 -
297 - $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
298 - $code = wp_remote_retrieve_response_code($response);
299 -
300 - if ($code >= 400) {
301 - return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to save feedback.', 'fluent-support'));
302 - }
303 -
304 - return $body;
305 - }
306 -
307 - public function deleteFeedback($feedbackId, $productId = null, $chatId = null)
308 - {
309 - $credentials = $this->resolveApiCredentials($productId);
310 -
311 - if (is_wp_error($credentials)) {
312 - return $credentials;
313 - }
314 -
315 - $payload = [
316 - 'bot_id' => $credentials['botId'],
317 - ];
318 -
319 - // Bind delete to the ticket's chat so upstream can enforce feedback-to-chat ownership.
320 - if (!empty($chatId)) {
321 - $payload['chat_id'] = $chatId;
322 - }
323 -
324 - $response = wp_remote_request($this->apiBaseUrl() . '/feedbacks/' . $feedbackId, [
325 - 'method' => 'DELETE',
326 - 'headers' => $this->requestHeaders($credentials['apiKey']),
327 - 'body' => wp_json_encode($payload),
328 - 'timeout' => 15,
329 - ]);
330 -
331 - if (is_wp_error($response)) {
332 - return $response;
333 - }
334 -
335 - $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
336 - $code = wp_remote_retrieve_response_code($response);
337 -
338 - if ($code >= 400) {
339 - return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to delete feedback.', 'fluent-support'));
340 - }
341 -
342 - return $body;
343 - }
344 -
345 125 private function resolveApiCredentials($productId)
346 126 {
347 127 $meta = Meta::where([
348 128 'object_type' => 'fluent_bot_settings',
@@ -347,31 +127,18 @@
347 127 $meta = Meta::where([
348 128 'object_type' => 'fluent_bot_settings',
349 129 'object_id' => 1,
350 130 'key' => '_fs_fluent_bot_config'
351 - ])->orderByDesc('id')->first();
131 + ])->first();
352 132
353 133 $config = $meta ? Helper::safeUnserialize($meta->value) : [];
354 - if (!is_array($config)) {
355 - $config = [];
356 - }
357 134
358 - // Default true for backward compatibility with configs saved before this flag existed.
359 - $generalBotEnabled = !array_key_exists('generalBotEnabled', $config)
360 - || filter_var($config['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN);
135 + $botId = $config['generalBotId'] ?? '';
361 136
362 - $generalBotId = $config['generalBotId'] ?? '';
363 - $botId = $generalBotEnabled ? $generalBotId : '';
364 - $matchedProductMapping = false;
365 -
366 137 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
367 138 foreach ($config['productMappings'] as $mapping) {
368 139 if ((int)$mapping['productId'] === (int)$productId) {
369 - $mappingBotId = trim((string)($mapping['botId'] ?? ''));
370 - if ($mappingBotId !== '') {
371 - $botId = $mappingBotId;
372 - $matchedProductMapping = true;
373 - }
140 + $botId = $mapping['botId'] ?? $botId;
374 141 break;
375 142 }
376 143 }
377 144 }
@@ -376,16 +143,8 @@
376 143 }
377 144 }
378 145
379 146 if (!$botId) {
380 - // Distinguish the "general bot disabled with no product mapping" case so admins
381 - // see a clear reason rather than a generic missing-credentials error.
382 - if (!$matchedProductMapping && !$generalBotEnabled) {
383 - return new \WP_Error(
384 - 'general_bot_disabled',
385 - __('General bot is disabled and no product-specific bot is configured for this product.', 'fluent-support')
386 - );
387 - }
388 147 return new \WP_Error(
389 148 'missing_bot_credentials',
390 149 __('Bot ID is not set for this product.', 'fluent-support')
391 150 );
@@ -390,47 +149,19 @@
390 149 __('Bot ID is not set for this product.', 'fluent-support')
391 150 );
392 151 }
393 152
394 - // The FluentBot API is team-scoped: a single API key authenticates every
395 - // bot in the team, so one general key covers both the general and any
396 - // product-specific bot. It is now required — the API rejects anonymous
397 - // calls — so surface a clear config error instead of a raw 401.
398 - $apiKey = trim((string)($config['generalApiKey'] ?? ''));
399 - if ($apiKey === '') {
400 - return new \WP_Error(
401 - 'missing_api_key',
402 - __('FluentBot API key is not set. Add it in the FluentBot integration settings.', 'fluent-support')
403 - );
404 - }
405 -
406 153 return [
407 - 'botId' => $botId,
408 - 'apiKey' => $apiKey,
154 + 'botId' => $botId
409 155 ];
410 156 }
411 157
412 - /**
413 - * Build the outbound request headers, attaching the team API key as a
414 - * Bearer token so upstream can authenticate + team-scope the call.
415 - */
416 - private function requestHeaders(string $apiKey): array
158 + private function getTicketMessages($ticket): array
417 159 {
418 - $headers = ['Content-Type' => 'application/json'];
419 -
420 - if ($apiKey !== '') {
421 - $headers['Authorization'] = 'Bearer ' . $apiKey;
422 - }
423 -
424 - return $headers;
425 - }
426 -
427 - private function getTicketMessages($ticket, $includeTicketContent = true): array
428 - {
429 160 $messages = [];
430 161 $ticketArray = $ticket->toArray();
431 162
432 - if ($includeTicketContent && !empty($ticketArray['content'])) {
163 + if (!empty($ticketArray['content'])) {
433 164 $messages[] = [
434 165 'role' => 'customer',
435 166 'message' => $this->cleanText($ticketArray['content']),
436 167 ];
@@ -446,41 +177,8 @@
446 177
447 178 return $messages;
448 179 }
449 180
450 - private function getSelectedTicketMessages($ticket, array $selectedIds, $includeTicketContent = true): array
451 - {
452 - $messages = [];
453 -
454 - if ($includeTicketContent && !empty($ticket->content)) {
455 - $messages[] = [
456 - 'role' => 'customer',
457 - 'message' => $this->cleanText($ticket->content),
458 - ];
459 - }
460 -
461 - $conversationIds = array_map('intval', array_filter($selectedIds, 'is_numeric'));
462 -
463 - if (!empty($conversationIds)) {
464 - $responses = Conversation::where('ticket_id', $ticket->id)
465 - ->whereIn('id', $conversationIds)
466 - ->where('conversation_type', 'response')
467 - ->with('person:id,person_type')
468 - ->orderBy('id', 'asc')
469 - ->get();
470 -
471 - foreach ($responses as $resp) {
472 - $role = ($resp->person && $resp->person->person_type === 'customer') ? 'customer' : 'support_agent';
473 - $messages[] = [
474 - 'role' => $role,
475 - 'message' => $this->cleanText($resp->content ?? ''),
476 - ];
477 - }
478 - }
479 -
480 - return $messages;
481 - }
482 -
483 181 private function getSimpleTicketMessages($ticket): array
484 182 {
485 183 $messages = [];
486 184 $ticketArray = $ticket->toArray();
@@ -506,9 +204,9 @@
506 204
507 205
508 206 private function cleanText(string $text): string
509 207 {
510 - return trim(wp_strip_all_tags($text));
208 + return trim(strip_tags($text));
511 209 }
512 210
513 211 private function getModifyResponsePresets(): array
514 212 {