PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 trunk, at app/Services/Integrations/FluentBot/FluentBotHelper.php

601 lines 21.7 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 /**
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 private function resolveApiCredentials($productId)
368 {
369 $meta = Meta::where([
370 'object_type' => 'fluent_bot_settings',
371 'object_id' => 1,
372 'key' => '_fs_fluent_bot_config'
373 ])->orderByDesc('id')->first();
374
375 $config = $meta ? Helper::safeUnserialize($meta->value) : [];
376 if (!is_array($config)) {
377 $config = [];
378 }
379
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);
383
384 $generalBotId = $config['generalBotId'] ?? '';
385 $botId = $generalBotEnabled ? $generalBotId : '';
386 $matchedProductMapping = false;
387
388 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
389 foreach ($config['productMappings'] as $mapping) {
390 if ((int)$mapping['productId'] === (int)$productId) {
391 $mappingBotId = trim((string)($mapping['botId'] ?? ''));
392 if ($mappingBotId !== '') {
393 $botId = $mappingBotId;
394 $matchedProductMapping = true;
395 }
396 break;
397 }
398 }
399 }
400
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 }
410 return new \WP_Error(
411 'missing_bot_credentials',
412 __('Bot ID is not set for this product.', 'fluent-support')
413 );
414 }
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
428 return [
429 'botId' => $botId,
430 'apiKey' => $apiKey,
431 ];
432 }
433
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
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 {
451 $messages = [];
452 $ticketArray = $ticket->toArray();
453
454 if ($includeTicketContent && !empty($ticketArray['content'])) {
455 $messages[] = [
456 'role' => 'customer',
457 'message' => $this->cleanText($ticketArray['content']),
458 ];
459 }
460
461 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
462 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'customer' : 'support_agent';
463 $messages[] = [
464 'role' => $role,
465 'message' => $this->cleanText(Arr::get($response, 'content', '')),
466 ];
467 }
468
469 return $messages;
470 }
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
505 private function getSimpleTicketMessages($ticket): array
506 {
507 $messages = [];
508 $ticketArray = $ticket->toArray();
509
510 if (!empty($ticketArray['content'])) {
511 $messages[] = [
512 'role' => 'visitor',
513 'message' => $this->cleanText($ticketArray['content']),
514 ];
515 }
516
517 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
518 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'visitor' : 'ai';
519 $messages[] = [
520 'role' => $role,
521 'message' => $this->cleanText(Arr::get($response, 'content', '')),
522 ];
523 }
524
525 return $messages;
526 }
527
528
529
530 private function cleanText(string $text): string
531 {
532 return trim(wp_strip_all_tags($text));
533 }
534
535 private function getModifyResponsePresets(): array
536 {
537 $presets = [
538 [
539 'label' => 'Improve Writing',
540 'text' => 'shorten',
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.'
542 ],
543 [
544 'label' => 'Fix Spelling & Grammar',
545 'text' => 'lengthen',
546 'description' => 'Apply AI to correct any spelling and grammatical errors in the text, ensuring it is free of mistakes and reads professionally.'
547 ],
548 [
549 'label' => 'Make Shorter',
550 'text' => 'friendly',
551 'description' => 'AI will modify the text to make it shorter and more casual, making it suitable for informal or friendly communication.'
552 ],
553 [
554 'label' => 'Make Longer',
555 'text' => 'professional',
556 'description' => 'Enhance the text by adding more details and using refined language to make it more formal and detailed, appropriate for professional settings.'
557 ],
558 [
559 'label' => 'Simplify Language',
560 'text' => 'simplify',
561 'description' => 'Utilize AI to simplify complex phrases and terminology, making the text easier to read and understand for a general audience.'
562 ]
563 ];
564
565 return apply_filters('fluent_support/get_modify_response_preset_prompts', $presets);
566 }
567
568 private function getCreateResponsePresets(): array
569 {
570 $presets = [
571 [
572 'label' => 'Request More Information',
573 'text' => 'requestInfo',
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.'
575 ],
576 [
577 'label' => 'Acknowledge Issue',
578 'text' => 'acknowledgeIssue',
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.'
580 ],
581 [
582 'label' => 'Provide Solution',
583 'text' => 'provideSolution',
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.'
585 ],
586 [
587 'label' => 'Follow Up',
588 'text' => 'followUp',
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.'
590 ],
591 [
592 'label' => 'Close Ticket',
593 'text' => 'closeTicket',
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.'
595 ]
596 ];
597
598 return apply_filters('fluent_support/get_create_response_preset_prompts', $presets);
599 }
600 }
601