| @@ -123,8 +123,182 @@ | ||
| 123 | 123 | return $data; |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | /** |
| 127 | + * POST a multipart/form-data body carrying one file, and decode the response. | |
| 128 | + * | |
| 129 | + * The JSON helper above cannot express a file upload, and WordPress ships no | |
| 130 | + * multipart builder — `wp_remote_post()` sends `body` as an array only as | |
| 131 | + * urlencoded form fields. So the body is assembled by hand here. The one | |
| 132 | + * caller is OpenAI's transcription endpoint, which takes the audio as a real | |
| 133 | + * file part; everything else in this layer is JSON. | |
| 134 | + * | |
| 135 | + * The file is streamed in from its path rather than passed around as a | |
| 136 | + * string by callers, so the bytes live in exactly one variable and are | |
| 137 | + * released when this method returns. | |
| 138 | + * | |
| 139 | + * @param string $url | |
| 140 | + * @param array $headers Auth headers. Content-Type is set here — a | |
| 141 | + * caller-supplied one would lack the boundary. | |
| 142 | + * @param array $fields Scalar form fields, e.g. `model`. | |
| 143 | + * @param array $file `[ 'name' => field name, 'filename' => …, 'type' => mime, 'path' => … ]` | |
| 144 | + * @param int $timeout | |
| 145 | + * @param int|null $status_code Out-param, as in post_json(). | |
| 146 | + * @return array|\WP_Error | |
| 147 | + */ | |
| 148 | + protected function post_multipart( $url, $headers, $fields, $file, $timeout = 120, &$status_code = null ) { | |
| 149 | + if ( ! is_readable( $file['path'] ) ) { | |
| 150 | + $status_code = 0; | |
| 151 | + return new \WP_Error( 'api_error', __( 'Could not read the uploaded file.', 'betterdocs' ) ); | |
| 152 | + } | |
| 153 | + | |
| 154 | + $response = $this->post_multipart_streamed( $url, $headers, $fields, $file, $timeout ); | |
| 155 | + | |
| 156 | + if ( null === $response ) { | |
| 157 | + $response = $this->post_multipart_buffered( $url, $headers, $fields, $file, $timeout ); | |
| 158 | + } | |
| 159 | + | |
| 160 | + if ( is_wp_error( $response ) ) { | |
| 161 | + $status_code = 0; | |
| 162 | + return new \WP_Error( 'api_error', sprintf( | |
| 163 | + /* translators: 1: provider label, 2: error message */ | |
| 164 | + __( 'Failed to connect to %1$s: %2$s', 'betterdocs' ), | |
| 165 | + $this->label(), | |
| 166 | + $response->get_error_message() | |
| 167 | + ) ); | |
| 168 | + } | |
| 169 | + | |
| 170 | + $status_code = (int) wp_remote_retrieve_response_code( $response ); | |
| 171 | + | |
| 172 | + $data = json_decode( wp_remote_retrieve_body( $response ), true ); | |
| 173 | + if ( ! is_array( $data ) ) { | |
| 174 | + return new \WP_Error( 'no_content', sprintf( | |
| 175 | + /* translators: %s: provider label */ | |
| 176 | + __( 'Empty or invalid response from %s.', 'betterdocs' ), | |
| 177 | + $this->label() | |
| 178 | + ) ); | |
| 179 | + } | |
| 180 | + | |
| 181 | + return $data; | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * Send the multipart body with cURL reading the file straight from disk. | |
| 186 | + * | |
| 187 | + * Building the body as a PHP string holds the whole recording in memory two | |
| 188 | + * or three times over (the bytes, the body, cURL's own copy) — a 24 MB file | |
| 189 | + * pushed a 128M host over its limit. Handing cURL a CURLFile instead lets it | |
| 190 | + * stream the part from the temp upload, so the file is never in PHP memory. | |
| 191 | + * | |
| 192 | + * The request still goes through wp_remote_post(), so proxies, SSL settings | |
| 193 | + * and every `http_*` filter apply as normal; only the body and the headers | |
| 194 | + * are replaced on the handle, right before it is sent. | |
| 195 | + * | |
| 196 | + * @return array|\WP_Error|null Null when cURL is not the transport, so the | |
| 197 | + * caller can fall back to the buffered body. | |
| 198 | + */ | |
| 199 | + protected function post_multipart_streamed( $url, $headers, $fields, $file, $timeout ) { | |
| 200 | + if ( ! function_exists( 'curl_init' ) || ! class_exists( '\CURLFile' ) ) { | |
| 201 | + return null; | |
| 202 | + } | |
| 203 | + | |
| 204 | + $streamed = false; | |
| 205 | + $attach = static function ( $handle, $r, $request_url ) use ( $url, $headers, $fields, $file, &$streamed ) { | |
| 206 | + if ( $request_url !== $url || $streamed ) { | |
| 207 | + return; | |
| 208 | + } | |
| 209 | + | |
| 210 | + $post = $fields; | |
| 211 | + $post[ $file['name'] ] = new \CURLFile( $file['path'], $file['type'], $file['filename'] ); | |
| 212 | + | |
| 213 | + // cURL writes its own multipart Content-Type, boundary included, so | |
| 214 | + // the header list is set without one. `Expect:` stops a 100-continue | |
| 215 | + // round trip on large bodies. | |
| 216 | + $lines = array( 'Expect:' ); | |
| 217 | + foreach ( $headers as $name => $value ) { | |
| 218 | + if ( 'content-type' !== strtolower( $name ) ) { | |
| 219 | + $lines[] = $name . ': ' . $value; | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + curl_setopt( $handle, CURLOPT_POSTFIELDS, $post ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- the handle WordPress is about to send. | |
| 224 | + curl_setopt( $handle, CURLOPT_HTTPHEADER, $lines ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt | |
| 225 | + $streamed = true; | |
| 226 | + }; | |
| 227 | + | |
| 228 | + add_action( 'http_api_curl', $attach, PHP_INT_MAX, 3 ); | |
| 229 | + | |
| 230 | + $response = wp_remote_post( $url, array( | |
| 231 | + 'headers' => $headers, | |
| 232 | + // Replaced on the handle above. Non-empty so the request is sent | |
| 233 | + // as a POST with a body on every transport. | |
| 234 | + 'body' => ' ', | |
| 235 | + 'timeout' => (int) $timeout, | |
| 236 | + ) ); | |
| 237 | + | |
| 238 | + remove_action( 'http_api_curl', $attach, PHP_INT_MAX ); | |
| 239 | + | |
| 240 | + // The hook never ran: something other than cURL sent that request, | |
| 241 | + // carrying the one-byte placeholder. Nothing was transcribed (the | |
| 242 | + // provider rejects an empty upload), so the buffered path runs it again. | |
| 243 | + return $streamed ? $response : null; | |
| 244 | + } | |
| 245 | + | |
| 246 | + /** | |
| 247 | + * The same request with the body built in PHP, for a host without cURL. | |
| 248 | + * | |
| 249 | + * @return array|\WP_Error | |
| 250 | + */ | |
| 251 | + protected function post_multipart_buffered( $url, $headers, $fields, $file, $timeout ) { | |
| 252 | + $boundary = wp_generate_password( 24, false ); | |
| 253 | + $eol = "\r\n"; | |
| 254 | + $body = ''; | |
| 255 | + | |
| 256 | + foreach ( $fields as $key => $value ) { | |
| 257 | + $body .= '--' . $boundary . $eol; | |
| 258 | + $body .= 'Content-Disposition: form-data; name="' . $key . '"' . $eol . $eol; | |
| 259 | + $body .= $value . $eol; | |
| 260 | + } | |
| 261 | + | |
| 262 | + $body .= '--' . $boundary . $eol; | |
| 263 | + $body .= 'Content-Disposition: form-data; name="' . $file['name'] . '"; filename="' . $file['filename'] . '"' . $eol; | |
| 264 | + $body .= 'Content-Type: ' . $file['type'] . $eol . $eol; | |
| 265 | + // Appended straight onto the body, never as `$bytes . $eol`: that | |
| 266 | + // expression builds a third full-size copy of the file before the append. | |
| 267 | + $body .= (string) @file_get_contents( $file['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp upload, not a remote fetch. | |
| 268 | + $body .= $eol; | |
| 269 | + $body .= '--' . $boundary . '--' . $eol; | |
| 270 | + | |
| 271 | + $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; | |
| 272 | + | |
| 273 | + return wp_remote_post( $url, array( | |
| 274 | + 'headers' => $headers, | |
| 275 | + 'body' => $body, | |
| 276 | + 'timeout' => (int) $timeout, | |
| 277 | + ) ); | |
| 278 | + } | |
| 279 | + | |
| 280 | + /** | |
| 281 | + * Transcribe an audio or video file to plain text. | |
| 282 | + * | |
| 283 | + * Default: not supported. A provider that can do it overrides this; callers | |
| 284 | + * are expected to check Core\WriteWithAI::platform_supports( 'transcription' ) | |
| 285 | + * first and show the switch-platform message, so reaching this is a bug | |
| 286 | + * rather than a user-facing path. | |
| 287 | + * | |
| 288 | + * @param array $file `[ 'path', 'filename', 'mime' ]` | |
| 289 | + * @param array $options | |
| 290 | + * @return string|\WP_Error Transcript text. | |
| 291 | + */ | |
| 292 | + public function transcribe( $file, $options = array() ) { | |
| 293 | + return new \WP_Error( 'no_transcription', sprintf( | |
| 294 | + /* translators: %s: provider label */ | |
| 295 | + __( '%s cannot transcribe audio or video.', 'betterdocs' ), | |
| 296 | + $this->label() | |
| 297 | + ) ); | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 127 | 301 | * Turn an HTTP status + the provider's raw error text into a clear, actionable |
| 128 | 302 | * message. Chiefly distinguishes a retired/unknown model (404) from a genuine |
| 129 | 303 | * quota / rate-limit rejection (429) — without this, a dead model id and a real |
| 130 | 304 | * quota error both surface the provider's raw text and look identical (a retired |