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

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

532 lines 18.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 public function generateStreamResponse($prompt, $ticket, $productId, $conversationId = null, $selectedConversations = null, $includeTicketContent = true, $seedMessages = null)
20 {
21 $prompt = apply_filters('fluent_support/generate_response', $prompt, $ticket);
22
23 $ticketMessages = [];
24 if ($selectedConversations !== null) {
25 $ticketMessages = $this->getSelectedTicketMessages($ticket, $selectedConversations, $includeTicketContent);
26 } else {
27 $ticketMessages = $this->getTicketMessages($ticket, $includeTicketContent);
28 }
29
30 $payload = [
31 'source' => 'fluent_support',
32 'prompt' => $prompt,
33 'stream' => true,
34 'chat_id' => $conversationId ?? null,
35 ];
36
37 if (!empty($ticketMessages)) {
38 $payload['ticket_conversation'] = $ticketMessages;
39 }
40
41 if (!empty($seedMessages)) {
42 $payload['seed_messages'] = $seedMessages;
43 }
44
45 return $this->makeStreamAPICall($payload, $prompt, $ticket->id, 'ticket_reply', $productId);
46 }
47
48 public function modifyResponse($prompt, $selectedText, $ticketId)
49 {
50 $prompt = apply_filters('fluent_support/modify_selected_text', $prompt);
51 $payload = [
52 'message' => "Instruction: {$prompt} Now apply this to the given text: {$selectedText}",
53 ];
54
55 return $this->makeAPICall($payload, $prompt, $ticketId);
56 }
57
58 public function generateTicketSummary($ticket)
59 {
60 $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.';
61 $prompt = apply_filters('fluent_support/generate_ticket_summary', $prompt);
62
63 $messages = $this->getSimpleTicketMessages($ticket);
64 $payload = [
65 'message' => "Instruction: {$prompt} Ticket Data: " . json_encode($messages),
66 ];
67
68 return $this->makeAPICall($payload, $prompt, $ticket->id);
69 }
70
71 public function generateTicketTone($ticket)
72 {
73 $prompt = 'What is the tone of this ticket? Is it positive, negative, or neutral? Provide a response with a single word.';
74 $prompt = apply_filters('fluent_support/find_customer_sentiment', $prompt);
75
76 $messages = $this->getSimpleTicketMessages($ticket);
77 $payload = [
78 'message' => "Instruction: {$prompt} Ticket Data: " . json_encode($messages),
79 ];
80
81 return $this->makeAPICall($payload, $prompt, $ticket->id);
82 }
83
84 public function getPresetPrompts(string $type): array
85 {
86 if ($type === 'modifyResponse') {
87 return $this->getModifyResponsePresets();
88 }
89
90 if ($type === 'createResponse') {
91 $customPresets = $this->getCustomPresets();
92 if (!empty($customPresets)) {
93 return $customPresets;
94 }
95 return $this->getCreateResponsePresets();
96 }
97
98 return [];
99 }
100
101 public function getCustomPresets(): array
102 {
103 $meta = Meta::where([
104 'object_type' => 'fluent_bot_settings',
105 'object_id' => 1,
106 'key' => '_fs_fluent_bot_presets'
107 ])->orderByDesc('id')->first();
108
109 if (!$meta) {
110 return [];
111 }
112
113 $presets = Helper::safeUnserialize($meta->value);
114
115 return is_array($presets) ? $presets : [];
116 }
117
118 public function saveCustomPresets(array $presets): array
119 {
120 $sanitized = [];
121 foreach ($presets as $index => $preset) {
122 if (empty($preset['label']) || empty($preset['description'])) {
123 continue;
124 }
125 $sanitized[] = [
126 'label' => sanitize_text_field($preset['label']),
127 'text' => sanitize_text_field($preset['text'] ?? 'preset_' . $index),
128 'description' => sanitize_textarea_field($preset['description']),
129 'position' => intval($preset['position'] ?? $index),
130 ];
131 }
132
133 usort($sanitized, function ($a, $b) {
134 return $a['position'] - $b['position'];
135 });
136
137 $where = [
138 'object_type' => 'fluent_bot_settings',
139 'object_id' => 1,
140 'key' => '_fs_fluent_bot_presets'
141 ];
142
143 $existing = Meta::where($where)->orderByDesc('id')->first();
144
145 if (empty($sanitized)) {
146 if ($existing) {
147 // Delete all rows for this key (including duplicates)
148 Meta::where($where)->delete();
149 }
150 return [];
151 }
152
153 $serialized = maybe_serialize($sanitized);
154
155 if ($existing) {
156 // Update the latest row; do not prune siblings — concurrent first-writes could
157 // race and delete each other's inserts, leaving zero rows (data loss).
158 // Reads use orderByDesc('id')->first() so duplicates are harmless at read time.
159 $existing->update(['value' => $serialized]);
160 } else {
161 Meta::create(array_merge($where, ['value' => $serialized]));
162 }
163
164 return $sanitized;
165 }
166
167 private function makeAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null )
168 {
169 $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
170
171 $credentials = $this->resolveApiCredentials($productId);
172
173 if (is_wp_error($credentials)) {
174 return $credentials;
175 }
176
177 // Use bot_id instead of botId for the new API
178 $payload['bot_id'] = $credentials['botId'];
179
180 $api = new FluentBotAPI($apiUrl);
181 $result = $api->makeRequest($ticketId, $prompt, $payload);
182
183 // For ticket_reply endpoint, return the full result with chat_id
184 // For other endpoints, return just the content for backward compatibility
185 if ($type === 'ticket_reply' && is_array($result) && isset($result['content'])) {
186 return $result;
187 } elseif (is_array($result) && isset($result['content'])) {
188 return $result['content'];
189 }
190
191 return $result;
192 }
193
194 private function makeStreamAPICall(array $payload, string $prompt, int $ticketId, string $type = 'default', $productId = null)
195 {
196 $apiUrl = static::BASE_URL . static::ENDPOINTS[$type];
197
198 $credentials = $this->resolveApiCredentials($productId);
199
200 if (is_wp_error($credentials)) {
201 echo "data: " . json_encode(['error' => $credentials->get_error_message()]) . "\n\n";
202 return;
203 }
204
205 // Use bot_id instead of botId for the new API
206 $payload['bot_id'] = $credentials['botId'];
207
208 $api = new FluentBotAPI($apiUrl);
209 $api->makeStreamRequest($ticketId, $prompt, $payload);
210 }
211
212 public function getChatMessages($chatId, $productId = null, $cursor = null)
213 {
214 $credentials = $this->resolveApiCredentials($productId);
215
216 if (is_wp_error($credentials)) {
217 return $credentials;
218 }
219
220 $botId = $credentials['botId'];
221 $url = static::BASE_URL . '/bots/' . $botId . '/chats/' . $chatId . '/messages';
222
223 if ($cursor) {
224 $url .= '?cursor=' . urlencode($cursor);
225 }
226
227 $response = wp_remote_get($url, [
228 'headers' => ['Content-Type' => 'application/json'],
229 'timeout' => 30,
230 ]);
231
232 if (is_wp_error($response)) {
233 return $response;
234 }
235
236 $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
237
238 if (wp_remote_retrieve_response_code($response) !== 200) {
239 return new \WP_Error(
240 'fluent_bot_messages_error',
241 $body['message'] ?? __('Failed to fetch chat messages.', 'fluent-support')
242 );
243 }
244
245 return $body;
246 }
247
248 public function createFeedback($messageId, $reaction, $comment, $productId = null, $chatId = null)
249 {
250 $credentials = $this->resolveApiCredentials($productId);
251
252 if (is_wp_error($credentials)) {
253 return $credentials;
254 }
255
256 $payload = [
257 'bot_id' => $credentials['botId'],
258 'message_id' => $messageId,
259 'reaction' => $reaction,
260 'comments' => $comment,
261 ];
262
263 // Bind feedback to the ticket's chat so upstream can enforce message-to-chat ownership.
264 if (!empty($chatId)) {
265 $payload['chat_id'] = $chatId;
266 }
267
268 $response = wp_remote_post(static::BASE_URL . '/feedbacks', [
269 'headers' => ['Content-Type' => 'application/json'],
270 'body' => wp_json_encode($payload),
271 'timeout' => 15,
272 ]);
273
274 if (is_wp_error($response)) {
275 return $response;
276 }
277
278 $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
279 $code = wp_remote_retrieve_response_code($response);
280
281 if ($code >= 400) {
282 return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to save feedback.', 'fluent-support'));
283 }
284
285 return $body;
286 }
287
288 public function deleteFeedback($feedbackId, $productId = null, $chatId = null)
289 {
290 $credentials = $this->resolveApiCredentials($productId);
291
292 if (is_wp_error($credentials)) {
293 return $credentials;
294 }
295
296 $payload = [
297 'bot_id' => $credentials['botId'],
298 ];
299
300 // Bind delete to the ticket's chat so upstream can enforce feedback-to-chat ownership.
301 if (!empty($chatId)) {
302 $payload['chat_id'] = $chatId;
303 }
304
305 $response = wp_remote_request(static::BASE_URL . '/feedbacks/' . $feedbackId, [
306 'method' => 'DELETE',
307 'headers' => ['Content-Type' => 'application/json'],
308 'body' => wp_json_encode($payload),
309 'timeout' => 15,
310 ]);
311
312 if (is_wp_error($response)) {
313 return $response;
314 }
315
316 $body = json_decode(wp_remote_retrieve_body($response), true) ?: [];
317 $code = wp_remote_retrieve_response_code($response);
318
319 if ($code >= 400) {
320 return new \WP_Error('feedback_error', $body['message'] ?? __('Failed to delete feedback.', 'fluent-support'));
321 }
322
323 return $body;
324 }
325
326 private function resolveApiCredentials($productId)
327 {
328 $meta = Meta::where([
329 'object_type' => 'fluent_bot_settings',
330 'object_id' => 1,
331 'key' => '_fs_fluent_bot_config'
332 ])->orderByDesc('id')->first();
333
334 $config = $meta ? Helper::safeUnserialize($meta->value) : [];
335 if (!is_array($config)) {
336 $config = [];
337 }
338
339 // Default true for backward compatibility with configs saved before this flag existed.
340 $generalBotEnabled = !array_key_exists('generalBotEnabled', $config)
341 || filter_var($config['generalBotEnabled'], FILTER_VALIDATE_BOOLEAN);
342
343 $generalBotId = $config['generalBotId'] ?? '';
344 $botId = $generalBotEnabled ? $generalBotId : '';
345 $matchedProductMapping = false;
346
347 if ($productId && !empty($config['productMappings']) && is_array($config['productMappings'])) {
348 foreach ($config['productMappings'] as $mapping) {
349 if ((int)$mapping['productId'] === (int)$productId) {
350 $mappingBotId = trim((string)($mapping['botId'] ?? ''));
351 if ($mappingBotId !== '') {
352 $botId = $mappingBotId;
353 $matchedProductMapping = true;
354 }
355 break;
356 }
357 }
358 }
359
360 if (!$botId) {
361 // Distinguish the "general bot disabled with no product mapping" case so admins
362 // see a clear reason rather than a generic missing-credentials error.
363 if (!$matchedProductMapping && !$generalBotEnabled) {
364 return new \WP_Error(
365 'general_bot_disabled',
366 __('General bot is disabled and no product-specific bot is configured for this product.', 'fluent-support')
367 );
368 }
369 return new \WP_Error(
370 'missing_bot_credentials',
371 __('Bot ID is not set for this product.', 'fluent-support')
372 );
373 }
374
375 return [
376 'botId' => $botId
377 ];
378 }
379
380 private function getTicketMessages($ticket, $includeTicketContent = true): array
381 {
382 $messages = [];
383 $ticketArray = $ticket->toArray();
384
385 if ($includeTicketContent && !empty($ticketArray['content'])) {
386 $messages[] = [
387 'role' => 'customer',
388 'message' => $this->cleanText($ticketArray['content']),
389 ];
390 }
391
392 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
393 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'customer' : 'support_agent';
394 $messages[] = [
395 'role' => $role,
396 'message' => $this->cleanText(Arr::get($response, 'content', '')),
397 ];
398 }
399
400 return $messages;
401 }
402
403 private function getSelectedTicketMessages($ticket, array $selectedIds, $includeTicketContent = true): array
404 {
405 $messages = [];
406
407 if ($includeTicketContent && !empty($ticket->content)) {
408 $messages[] = [
409 'role' => 'customer',
410 'message' => $this->cleanText($ticket->content),
411 ];
412 }
413
414 $conversationIds = array_map('intval', array_filter($selectedIds, 'is_numeric'));
415
416 if (!empty($conversationIds)) {
417 $responses = Conversation::where('ticket_id', $ticket->id)
418 ->whereIn('id', $conversationIds)
419 ->where('conversation_type', 'response')
420 ->with('person:id,person_type')
421 ->orderBy('id', 'asc')
422 ->get();
423
424 foreach ($responses as $resp) {
425 $role = ($resp->person && $resp->person->person_type === 'customer') ? 'customer' : 'support_agent';
426 $messages[] = [
427 'role' => $role,
428 'message' => $this->cleanText($resp->content ?? ''),
429 ];
430 }
431 }
432
433 return $messages;
434 }
435
436 private function getSimpleTicketMessages($ticket): array
437 {
438 $messages = [];
439 $ticketArray = $ticket->toArray();
440
441 if (!empty($ticketArray['content'])) {
442 $messages[] = [
443 'role' => 'visitor',
444 'message' => $this->cleanText($ticketArray['content']),
445 ];
446 }
447
448 foreach (Arr::get($ticketArray, 'responses', []) as $response) {
449 $role = Arr::get($response, 'person.person_type') === 'customer' ? 'visitor' : 'ai';
450 $messages[] = [
451 'role' => $role,
452 'message' => $this->cleanText(Arr::get($response, 'content', '')),
453 ];
454 }
455
456 return $messages;
457 }
458
459
460
461 private function cleanText(string $text): string
462 {
463 return trim(wp_strip_all_tags($text));
464 }
465
466 private function getModifyResponsePresets(): array
467 {
468 $presets = [
469 [
470 'label' => 'Improve Writing',
471 'text' => 'shorten',
472 'description' => 'Use AI to refine the text by removing unnecessary words and making it more concise while retaining the original meaning and key information.'
473 ],
474 [
475 'label' => 'Fix Spelling & Grammar',
476 'text' => 'lengthen',
477 'description' => 'Apply AI to correct any spelling and grammatical errors in the text, ensuring it is free of mistakes and reads professionally.'
478 ],
479 [
480 'label' => 'Make Shorter',
481 'text' => 'friendly',
482 'description' => 'AI will modify the text to make it shorter and more casual, making it suitable for informal or friendly communication.'
483 ],
484 [
485 'label' => 'Make Longer',
486 'text' => 'professional',
487 'description' => 'Enhance the text by adding more details and using refined language to make it more formal and detailed, appropriate for professional settings.'
488 ],
489 [
490 'label' => 'Simplify Language',
491 'text' => 'simplify',
492 'description' => 'Utilize AI to simplify complex phrases and terminology, making the text easier to read and understand for a general audience.'
493 ]
494 ];
495
496 return apply_filters('fluent_support/get_modify_response_preset_prompts', $presets);
497 }
498
499 private function getCreateResponsePresets(): array
500 {
501 $presets = [
502 [
503 'label' => 'Request More Information',
504 'text' => 'requestInfo',
505 '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.'
506 ],
507 [
508 'label' => 'Acknowledge Issue',
509 'text' => 'acknowledgeIssue',
510 '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.'
511 ],
512 [
513 'label' => 'Provide Solution',
514 'text' => 'provideSolution',
515 '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.'
516 ],
517 [
518 'label' => 'Follow Up',
519 'text' => 'followUp',
520 '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.'
521 ],
522 [
523 'label' => 'Close Ticket',
524 'text' => 'closeTicket',
525 '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.'
526 ]
527 ];
528
529 return apply_filters('fluent_support/get_create_response_preset_prompts', $presets);
530 }
531 }
532