PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.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
fluent-support / app / Services / Integrations / FluentBot / FluentBotHelper.php

FluentBotHelper.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.3.1, at app/Services/Integrations/FluentBot/FluentBotHelper.php

579 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace FluentSupport\App\Services\Integrations\FluentBot;
3
4 use FluentSupport\App\Models\Meta;
5 use FluentSupport\App\Models\Conversation;
6 use FluentSupport\App\Services\Integrations\FluentBot\FluentBotAPI;
7 use FluentSupport\Framework\Support\Arr;
8 use FluentSupport\App\Services\Helper;
9 use WP_Error;
10 class FluentBotHelper
11 {
12 const BASE_URL = 'https://dash.fluentbot.ai/api';
13
14 const ENDPOINTS = [
15 'default' => '/responses',
16 'ticket_reply' => '/chat/fs',
17 ];
18
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
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 {
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
44 $payload = [
45 'source' => 'fluent_support',
46 'prompt' => $prompt,
47 'stream' => true,
48 'chat_id' => $conversationId ?? null,
49 'enable_web_search' => (bool) $webSearch,
50 'temperature' => (float) $temperature,
51 ];
52
53 if (!empty($ticketMessages)) {
54 $payload['ticket_conversation'] = $ticketMessages;
55 }
56
57 if (!empty($seedMessages)) {
58 $payload['seed_messages'] = $seedMessages;
59 }
60
61 return $this->makeStreamAPICall($payload, $prompt, $ticket->id, 'ticket_reply', $productId);
62 }
63
64 public function modifyResponse($prompt, $selectedText, $ticketId)
65 {
66 $prompt = apply_filters('fluent_support/modify_selected_text', $prompt);
67 $payload = [
68 'message' => "Instruction: {$prompt} Now apply this to the given text: {$selectedText}",
69 ];
70
71 return $this->makeAPICall($payload, $prompt, $ticketId);
72 }
73
74 public function generateTicketSummary($ticket)
75 {
76 $prompt = 'Provide a summary of the ticket from the customer\'s perspective. Each step should start with "-". Break it down into concise steps, with a maximum of 6 steps. Each step should be within 6 words per line. Use full stops for separation.';
77 $prompt = apply_filters('fluent_support/generate_ticket_summary', $prompt);
78
79 $messages = $this->getSimpleTicketMessages($ticket);
80 $payload = [
81 'message' => "Instruction: {$prompt} Ticket Data: " . json_encode($messages),
82 ];
83
84 return $this->makeAPICall($payload, $prompt, $ticket->id);
85 }
86
87 public function generateTicketTone($ticket)
88 {
89 $prompt = 'What is the tone of this ticket? Is it positive, negative, or neutral? Provide a response with a single word.';
90 $prompt = apply_filters('fluent_support/find_customer_sentiment', $prompt);
91
92 $messages = $this->getSimpleTicketMessages($ticket);
93 $payload = [
94 'message' => "Instruction: {$prompt} Ticket Data: " . json_encode($messages),
95 ];
96
97 return $this->makeAPICall($payload, $prompt, $ticket->id);
98 }
99
100 public function getPresetPrompts(string $type): array
101 {
102 if ($type === 'modifyResponse') {
103 return $this->getModifyResponsePresets();
104 }
105
106 if ($type === 'createResponse') {
107 $customPresets = $this->getCustomPresets();
108 if (!empty($customPresets)) {
109 return $customPresets;
110 }
111 return $this->getCreateResponsePresets();
112 }
113
114 return [];
115 }
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
183 private function makeAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null )
184 {
185 $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
186
187 $credentials = $this->resolveApiCredentials($productId);
188
189 if (is_wp_error($credentials)) {
190 return $credentials;
191 }
192
193 // Use bot_id instead of botId for the new API
194 $payload['bot_id'] = $credentials['botId'];
195
196 $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
197 $result = $api->makeRequest($ticketId, $prompt, $payload);
198
199 // For ticket_reply endpoint, return the full result with chat_id
200 // For other endpoints, return just the content for backward compatibility
201 if ($type === 'ticket_reply' && is_array($result) && isset($result['content'])) {
202 return $result;
203 } elseif (is_array($result) && isset($result['content'])) {
204 return $result['content'];
205 }
206
207 return $result;
208 }
209
210 private function makeStreamAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null)
211 {
212 $apiUrl = $this->apiBaseUrl() . static::ENDPOINTS[$type];
213
214 $credentials = $this->resolveApiCredentials($productId);
215
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";
220 echo "data: " . json_encode(['error' => $credentials->get_error_message()]) . "\n\n";
221 return;
222 }
223
224 // Use bot_id instead of botId for the new API
225 $payload['bot_id'] = $credentials['botId'];
226
227 $api = new FluentBotAPI($apiUrl, $credentials['apiKey']);
228 $api->makeStreamRequest($ticketId, $prompt, $payload);
229 }
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 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 private function resolveApiCredentials($productId)
346 {
347 $meta = Meta::where([
348 'object_type' => 'fluent_bot_settings',
349 'object_id' => 1,
350 'key' => '_fs_fluent_bot_config'
351 ])->orderByDesc('id')->first();
352
353 $config = $meta ? Helper::safeUnserialize($meta->value) : [];
354 if (!is_array($config)) {
355 $config = [];
356 }
357
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);
361
362 $generalBotId = $config['generalBotId'] ?? '';
363 $botId = $generalBotEnabled ? $generalBotId : '';
364 $matchedProductMapping = false;
365
366 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
367 foreach ($config['productMappings'] as $mapping) {
368 if ((int)$mapping['productId'] === (int)$productId) {
369 $mappingBotId = trim((string)($mapping['botId'] ?? ''));
370 if ($mappingBotId !== '') {
371 $botId = $mappingBotId;
372 $matchedProductMapping = true;
373 }
374 break;
375 }
376 }
377 }
378
379 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 return new \WP_Error(
389 'missing_bot_credentials',
390 __('Bot ID is not set for this product.', 'fluent-support')
391 );
392 }
393
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 return [
407 'botId' => $botId,
408 'apiKey' => $apiKey,
409 ];
410 }
411
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
417 {
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 $messages = [];
430 $ticketArray = $ticket->toArray();
431
432 if ($includeTicketContent && !empty($ticketArray['content'])) {
433 $messages[] = [
434 'role' => 'customer',
435 'message' => $this->cleanText($ticketArray['content']),
436 ];
437 }
438
439 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
440 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'customer' : 'support_agent';
441 $messages[] = [
442 'role' => $role,
443 'message' => $this->cleanText(Arr::get($response, 'content', '')),
444 ];
445 }
446
447 return $messages;
448 }
449
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 private function getSimpleTicketMessages($ticket): array
484 {
485 $messages = [];
486 $ticketArray = $ticket->toArray();
487
488 if (!empty($ticketArray['content'])) {
489 $messages[] = [
490 'role' => 'visitor',
491 'message' => $this->cleanText($ticketArray['content']),
492 ];
493 }
494
495 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
496 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'visitor' : 'ai';
497 $messages[] = [
498 'role' => $role,
499 'message' => $this->cleanText(Arr::get($response, 'content', '')),
500 ];
501 }
502
503 return $messages;
504 }
505
506
507
508 private function cleanText(string $text): string
509 {
510 return trim(wp_strip_all_tags($text));
511 }
512
513 private function getModifyResponsePresets(): array
514 {
515 $presets = [
516 [
517 'label' => 'Improve Writing',
518 'text' => 'shorten',
519 'description' => 'Use AI to refine the text by removing unnecessary words and making it more concise while retaining the original meaning and key information.'
520 ],
521 [
522 'label' => 'Fix Spelling & Grammar',
523 'text' => 'lengthen',
524 'description' => 'Apply AI to correct any spelling and grammatical errors in the text, ensuring it is free of mistakes and reads professionally.'
525 ],
526 [
527 'label' => 'Make Shorter',
528 'text' => 'friendly',
529 'description' => 'AI will modify the text to make it shorter and more casual, making it suitable for informal or friendly communication.'
530 ],
531 [
532 'label' => 'Make Longer',
533 'text' => 'professional',
534 'description' => 'Enhance the text by adding more details and using refined language to make it more formal and detailed, appropriate for professional settings.'
535 ],
536 [
537 'label' => 'Simplify Language',
538 'text' => 'simplify',
539 'description' => 'Utilize AI to simplify complex phrases and terminology, making the text easier to read and understand for a general audience.'
540 ]
541 ];
542
543 return apply_filters('fluent_support/get_modify_response_preset_prompts', $presets);
544 }
545
546 private function getCreateResponsePresets(): array
547 {
548 $presets = [
549 [
550 'label' => 'Request More Information',
551 'text' => 'requestInfo',
552 '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.'
553 ],
554 [
555 'label' => 'Acknowledge Issue',
556 'text' => 'acknowledgeIssue',
557 '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.'
558 ],
559 [
560 'label' => 'Provide Solution',
561 'text' => 'provideSolution',
562 '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.'
563 ],
564 [
565 'label' => 'Follow Up',
566 'text' => 'followUp',
567 '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.'
568 ],
569 [
570 'label' => 'Close Ticket',
571 'text' => 'closeTicket',
572 '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.'
573 ]
574 ];
575
576 return apply_filters('fluent_support/get_create_response_preset_prompts', $presets);
577 }
578 }
579