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 +164 -68 1.10.3 → 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 }
@@ -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,10 +64,9 @@
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
67 - // Note: Using cURL here because WordPress HTTP API doesn't support streaming SSE responses
68 + // Raw cURL because the WP HTTP API can't stream SSE.
68 69 // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
69 70 // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
70 71 // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_exec
71 72 // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
@@ -75,67 +76,27 @@
75 76 $ch = curl_init();
76 77 curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
77 78 curl_setopt($ch, CURLOPT_POST, true);
78 79 curl_setopt($ch, CURLOPT_POSTFIELDS, wp_json_encode($args));
79 - curl_setopt($ch, CURLOPT_HTTPHEADER, [
80 - 'Content-Type: application/json',
81 - !empty($this->apiKey) ? 'Authorization: Bearer ' . $this->apiKey : ''
82 - ]);
80 + $streamHeaders = ['Content-Type: application/json'];
81 + if ($this->apiKey !== '') {
82 + $streamHeaders[] = 'Authorization: Bearer ' . $this->apiKey;
83 + }
84 + curl_setopt($ch, CURLOPT_HTTPHEADER, $streamHeaders);
83 85
84 86 $buffer = '';
85 87 $conversationId = null;
88 + $streamTokens = 0;
86 89
87 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$buffer, &$conversationId) {
88 - $buffer .= $data;
89 -
90 - // Process complete SSE events from the AI API
91 - $events = explode("\n\n", $buffer);
92 - $buffer = array_pop($events); // Keep incomplete event in buffer
93 -
94 - foreach ($events as $event) {
95 - if (trim($event)) {
96 - $lines = explode("\n", $event);
97 - $eventType = '';
98 - $eventId = '';
99 - $eventDataLines = [];
100 -
101 - foreach ($lines as $line) {
102 - if (strpos($line, 'event: ') === 0) {
103 - $eventType = trim(substr($line, 7));
104 - } elseif (strpos($line, 'id: ') === 0) {
105 - $eventId = trim(substr($line, 4));
106 - } elseif (strpos($line, 'data: ') === 0) {
107 - $eventDataLines[] = substr($line, 6);
108 - }
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);
109 98 }
110 -
111 - // Forward the event to the browser with proper formatting
112 - if ($eventType) {
113 - echo "event: ".esc_html($eventType)."\n";
114 -
115 - // Include ID if present
116 - if ($eventId !== '') {
117 - echo "id: ".esc_html($eventId)."\n";
118 - }
119 -
120 - // Handle multiple data lines properly
121 - if (!empty($eventDataLines)) {
122 - foreach ($eventDataLines as $dataLine) {
123 - echo "data: ".esc_html($dataLine)."\n";
124 - }
125 - } else {
126 - echo "data: \n";
127 - }
128 -
129 - echo "\n";
130 -
131 - // Store conversation_id for later use
132 - if ($eventType === 'conversation_id' && !empty($eventDataLines)) {
133 - $conversationId = $eventDataLines[0];
134 - }
135 -
136 - flush();
137 - }
138 99 }
139 100 }
140 101
141 102 return strlen($data);
@@ -163,19 +124,154 @@
163 124 // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
164 125 // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_error
165 126
166 127 if ($httpCode === 200) {
167 - do_action('fluent_support/ai_response_success', $ticketId, $prompt, 0, "Fluent Bot");
128 + do_action('fluent_support/ai_response_success', $ticketId, $prompt, $streamTokens, "FluentBot");
168 129 }
169 130 }
170 131
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;
143 +
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);
173 + }
174 + }
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 +
237 + return strlen($data);
238 + });
239 +
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);
248 + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
249 + curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // Smaller buffer for faster streaming
250 +
251 + curl_exec($ch);
252 +
253 + if (curl_error($ch)) {
254 + echo "event: error\n";
255 + echo "data: " . wp_json_encode(['error' => curl_error($ch)]) . "\n\n";
256 + flush();
257 + }
258 +
259 + curl_close($ch);
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
265 + }
266 +
171 267 protected function sendRequest(array $payload)
172 268 {
173 269 $headers = [
174 - 'Content-Type' => 'application/json',
270 + 'Content-Type' => 'application/json',
175 271 ];
176 - // Add Authorization header only if API key is provided
177 - if (!empty($this->apiKey)) {
272 +
273 + if ($this->apiKey !== '') {
178 274 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
179 275 }
180 276
181 277 $timeout = apply_filters('fs_ai_request_timeout', 60);