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