PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
← All changes | app/Services/Integrations/FluentBot/FluentBotAPI.php +178 -67 1.10.0 → 2.4.5 View file →
@@ -5,15 +5,16 @@
5 5 use WP_Error;
6 6
7 7 class FluentBotAPI
8 8 {
9 + protected $apiUrl;
10 +
9 11 protected $apiKey;
10 - protected $apiUrl;
11 12
12 - public function __construct(?string $apiKey, string $apiUrl)
13 + public function __construct(string $apiUrl, string $apiKey = '')
13 14 {
15 + $this->apiUrl = $apiUrl;
14 16 $this->apiKey = $apiKey;
15 - $this->apiUrl = $apiUrl;
16 17 }
17 18
18 19 public function makeRequest(int $ticketId, $prompt, array $args = [])
19 20 {
@@ -24,9 +25,10 @@
24 25 $code = $response->get_error_code();
25 26 return new \WP_Error($code, $message);
26 27 }
27 28
28 - $responseBody = json_decode(wp_remote_retrieve_body($response), true) ?? [];
29 + $rawBody = wp_remote_retrieve_body($response);
30 + $responseBody = json_decode($rawBody, true) ?? [];
29 31
30 32 if (!$responseBody || !is_array($responseBody)) {
31 33 return new \WP_Error('fluent_bot_error', __('Invalid or empty response from API', 'fluent-support'));
32 34 }
@@ -39,9 +41,9 @@
39 41 $statusCode = wp_remote_retrieve_response_code($response);
40 42
41 43 if ($statusCode !== 200) {
42 44 $error = $responseBody['message'] ?? __('Something went wrong.', 'fluent-support');
43 - return new \WP_Error($statusCode, __($error, 'fluent-support'));
45 + return new \WP_Error($statusCode, $error);
44 46 }
45 47
46 48 $content = $responseBody['response'] ?? '';
47 49
@@ -48,15 +50,15 @@
48 50 if (empty($content)) {
49 51 return new \WP_Error('fluent_bot_error', __('No AI response found in the API response.', 'fluent-support'));
50 52 }
51 53
52 - $totalTokens = $responseBody['token_usage']['total_tokens'] ?? $responseBody['totalTokens'] ?? 0;
53 - do_action('fluent_support/ai_response_success', $ticketId, $prompt, $totalTokens, "Fluent Bot");
54 + $tokenUsage = $responseBody['token_usage'] ?? [];
55 + $totalTokens = ($tokenUsage['input_tokens'] ?? 0) + ($tokenUsage['output_tokens'] ?? 0);
56 + do_action('fluent_support/ai_response_success', $ticketId, $prompt, $totalTokens, "FluentBot");
54 57
55 - // Return both content and conversation_id if available
56 58 return [
57 59 'content' => $content,
58 - 'conversation_id' => $responseBody['conversation_id'] ?? null
60 + 'chat_id' => $responseBody['chat_id'] ?? null
59 61 ];
60 62 }
61 63
62 64 public function makeStreamRequest(int $ticketId, $prompt, array $args = [])
@@ -62,105 +64,214 @@
62 64 public function makeStreamRequest(int $ticketId, $prompt, array $args = [])
63 65 {
64 66 $timeout = apply_filters('fs_ai_request_timeout', 120);
65 67
66 - // Use cURL for streaming
68 + // Raw cURL because the WP HTTP API can't stream SSE.
69 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
70 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
71 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_exec
72 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
73 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
74 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
75 + // PluginCheck:ignoreFile
67 76 $ch = curl_init();
68 77 curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
69 78 curl_setopt($ch, CURLOPT_POST, true);
70 79 curl_setopt($ch, CURLOPT_POSTFIELDS, wp_json_encode($args));
71 - curl_setopt($ch, CURLOPT_HTTPHEADER, [
72 - 'Content-Type: application/json',
73 - !empty($this->apiKey) ? 'Authorization: Bearer ' . $this->apiKey : ''
74 - ]);
80 + $streamHeaders = ['Content-Type: application/json'];
81 + if ($this->apiKey !== '') {
82 + $streamHeaders[] = 'Authorization: Bearer ' . $this->apiKey;
83 + }
84 + curl_setopt($ch, CURLOPT_HTTPHEADER, $streamHeaders);
75 85
76 86 $buffer = '';
77 87 $conversationId = null;
88 + $streamTokens = 0;
78 89
79 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$buffer, &$conversationId) {
80 - $buffer .= $data;
90 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$buffer, &$conversationId, &$streamTokens) {
91 + foreach ($this->drainSseChunk($data, $buffer) as $parsed) {
92 + if ($parsed['event'] === 'chat_id' && !empty($parsed['data'])) {
93 + $conversationId = $parsed['data'][0];
94 + } elseif ($parsed['event'] === 'token_usage' && !empty($parsed['data'])) {
95 + $usage = json_decode($parsed['data'][0], true);
96 + if (is_array($usage)) {
97 + $streamTokens = ($usage['input_tokens'] ?? 0) + ($usage['output_tokens'] ?? 0);
98 + }
99 + }
100 + }
81 101
82 - // Process complete SSE events from the AI API
83 - $events = explode("\n\n", $buffer);
84 - $buffer = array_pop($events); // Keep incomplete event in buffer
102 + return strlen($data);
103 + });
85 104
86 - foreach ($events as $event) {
87 - if (trim($event)) {
88 - $lines = explode("\n", $event);
89 - $eventType = '';
90 - $eventId = '';
91 - $eventDataLines = [];
105 + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
106 + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
107 + curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // Smaller buffer for faster streaming
92 108
93 - foreach ($lines as $line) {
94 - if (strpos($line, 'event: ') === 0) {
95 - $eventType = trim(substr($line, 7));
96 - } elseif (strpos($line, 'id: ') === 0) {
97 - $eventId = trim(substr($line, 4));
98 - } elseif (strpos($line, 'data: ') === 0) {
99 - $eventDataLines[] = substr($line, 6);
100 - }
101 - }
109 + $result = curl_exec($ch);
110 + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
102 111
103 - // Forward the event to the browser with proper formatting
104 - if ($eventType) {
105 - echo "event: {$eventType}\n";
112 + if (curl_error($ch)) {
113 + echo "event: error\n";
114 + echo "data: " . json_encode(['error' => curl_error($ch)]) . "\n\n";
115 + flush();
116 + }
106 117
107 - // Include ID if present
108 - if ($eventId !== '') {
109 - echo "id: {$eventId}\n";
110 - }
118 + curl_close($ch);
111 119
112 - // Handle multiple data lines properly
113 - if (!empty($eventDataLines)) {
114 - foreach ($eventDataLines as $dataLine) {
115 - echo "data: {$dataLine}\n";
116 - }
117 - } else {
118 - echo "data: \n";
119 - }
120 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_init
121 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
122 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_exec
123 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_close
124 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
125 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_error
120 126
121 - echo "\n";
127 + if ($httpCode === 200) {
128 + do_action('fluent_support/ai_response_success', $ticketId, $prompt, $streamTokens, "FluentBot");
129 + }
130 + }
122 131
123 - // Store conversation_id for later use
124 - if ($eventType === 'conversation_id' && !empty($eventDataLines)) {
125 - $conversationId = $eventDataLines[0];
126 - }
132 + /**
133 + * Forward every complete SSE frame in $data to the browser, return the parsed frames.
134 + * $buffer holds the partial trailing frame across cURL writes, so pass it by reference.
135 + *
136 + * @param string $data raw bytes from cURL
137 + * @param string $buffer incomplete frame carried over from the previous write
138 + * @return array<int, array{event: string, data: array<int, string>}>
139 + */
140 + private function drainSseChunk(string $data, string &$buffer): array
141 + {
142 + $buffer .= $data;
127 143
128 - flush();
129 - }
144 + $events = explode("\n\n", $buffer);
145 + $buffer = array_pop($events); // Keep incomplete event in buffer
146 +
147 + $parsed = [];
148 +
149 + foreach ($events as $event) {
150 + if (!trim($event)) {
151 + continue;
152 + }
153 +
154 + // Forward keepalive comments as-is so proxies don't hit an idle timeout.
155 + if (strpos(ltrim($event), ':') === 0) {
156 + echo $event . "\n\n";
157 + flush();
158 + continue;
159 + }
160 +
161 + $lines = explode("\n", $event);
162 + $eventType = '';
163 + $eventId = '';
164 + $eventDataLines = [];
165 +
166 + foreach ($lines as $line) {
167 + if (strpos($line, 'event: ') === 0) {
168 + $eventType = trim((string)substr($line, 7));
169 + } elseif (strpos($line, 'id: ') === 0) {
170 + $eventId = trim((string)substr($line, 4));
171 + } elseif (strpos($line, 'data: ') === 0) {
172 + $eventDataLines[] = (string)substr($line, 6);
130 173 }
131 174 }
132 175
176 + if (!$eventType) {
177 + continue;
178 + }
179 +
180 + echo "event: ".esc_html($eventType)."\n";
181 +
182 + if ($eventId !== '') {
183 + echo "id: ".esc_html($eventId)."\n";
184 + }
185 +
186 + if (!empty($eventDataLines)) {
187 + foreach ($eventDataLines as $dataLine) {
188 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw SSE payload from trusted bot endpoint; rendered output sanitized client-side
189 + echo "data: ".$dataLine."\n";
190 + }
191 + } else {
192 + echo "data: \n";
193 + }
194 +
195 + echo "\n";
196 + flush();
197 +
198 + $parsed[] = ['event' => $eventType, 'data' => $eventDataLines];
199 + }
200 +
201 + return $parsed;
202 + }
203 +
204 + /**
205 + * Reconnect to an in-flight turn: replay its buffered SSE and tail it live.
206 + * Same wire format as makeStreamRequest. Upstream ends on `__done__` or an `idle` frame.
207 + */
208 + public function makeResumeStreamRequest()
209 + {
210 + // Raw cURL because the WP HTTP API can't stream SSE.
211 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
212 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
213 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_exec
214 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
215 + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
216 + // PluginCheck:ignoreFile
217 + $ch = curl_init();
218 + curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
219 + curl_setopt($ch, CURLOPT_HTTPGET, true);
220 +
221 + $streamHeaders = ['Accept: text/event-stream'];
222 + if ($this->apiKey !== '') {
223 + $streamHeaders[] = 'Authorization: Bearer ' . $this->apiKey;
224 + }
225 + curl_setopt($ch, CURLOPT_HTTPHEADER, $streamHeaders);
226 +
227 + $buffer = '';
228 +
229 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$buffer) {
230 + // Stop when the agent leaves. The turn itself is persisted upstream.
231 + if (connection_aborted()) {
232 + return 0;
233 + }
234 +
235 + $this->drainSseChunk($data, $buffer);
236 +
133 237 return strlen($data);
134 238 });
135 239
136 - curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
240 + // connection_aborted() can lag behind proxies and buffering, so cap the tail
241 + // or a refresh mid-turn can pin a worker. The client refetches a cut tail.
242 + curl_setopt($ch, CURLOPT_TIMEOUT, apply_filters('fs_ai_resume_timeout', 120));
243 + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
244 + // Upstream sends keepalives during quiet gaps, so a stall here means it's dead.
245 + curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1);
246 + curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, apply_filters('fs_ai_resume_stall_timeout', 60));
247 + curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
137 248 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
138 249 curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // Smaller buffer for faster streaming
139 250
140 - $result = curl_exec($ch);
141 - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
251 + curl_exec($ch);
142 252
143 253 if (curl_error($ch)) {
144 254 echo "event: error\n";
145 - echo "data: " . json_encode(['error' => curl_error($ch)]) . "\n\n";
255 + echo "data: " . wp_json_encode(['error' => curl_error($ch)]) . "\n\n";
146 256 flush();
147 257 }
148 258
149 259 curl_close($ch);
150 -
151 - if ($httpCode === 200) {
152 - do_action('fluent_support/ai_response_success', $ticketId, $prompt, 0, "Fluent Bot");
153 - }
260 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_init
261 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
262 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_exec
263 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_close
264 + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_error
154 265 }
155 266
156 267 protected function sendRequest(array $payload)
157 268 {
158 269 $headers = [
159 - 'Content-Type' => 'application/json',
270 + 'Content-Type' => 'application/json',
160 271 ];
161 - // Add Authorization header only if API key is provided
162 - if (!empty($this->apiKey)) {
272 +
273 + if ($this->apiKey !== '') {
163 274 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
164 275 }
165 276
166 277 $timeout = apply_filters('fs_ai_request_timeout', 60);