PluginProbe
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher / trunk
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher vtrunk
5.3.4 5.3.3 5.3.2 5.3.1 5.3.0 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.1.8 5.1.9 5.2.0 5.2.1 5.2.10 5.2.11 5.2.12 5.2.13 5.2.14 5.2.15 5.2.16 5.2.17 5.2.18 5.2.2 5.2.3 All 118 releases
wp-scheduled-posts / includes / API / AICaption.php

AICaption.php in SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher trunk, at includes/API/AICaption.php

433 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPSP\API;
4
5 use WPSP\Helper;
6
7 /**
8 * AI Caption API Handler
9 *
10 * Generates social captions for a post via the OpenAI Chat Completions API.
11 * Reads the OpenAI API key saved on the SchedulePress Settings → AI panel
12 * (`openai_api_key` in the `wpsp_settings_v5` option).
13 *
14 * Route: POST wp-scheduled-posts/v1/ai-caption/{post_id}
15 * Response: { success: true, captions: { facebook: "...", twitter: "..." } }
16 *
17 * @since 5.3.0
18 */
19 class AICaption
20 {
21 /**
22 * Instance of this class.
23 *
24 * @var object
25 */
26 protected static $instance = null;
27
28 /**
29 * OpenAI chat completions endpoint.
30 */
31 const OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
32
33 /**
34 * OpenAI models endpoint — used to cheaply validate an API key.
35 */
36 const OPENAI_MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
37
38 /**
39 * Per-platform display name + character budget used to guide the model.
40 *
41 * @var array
42 */
43 protected $platform_meta = [
44 'facebook' => ['name' => 'Facebook', 'limit' => 63206],
45 'twitter' => ['name' => 'X (Twitter)', 'limit' => 280],
46 'linkedin' => ['name' => 'LinkedIn', 'limit' => 3000],
47 'pinterest' => ['name' => 'Pinterest', 'limit' => 500],
48 'instagram' => ['name' => 'Instagram', 'limit' => 2200],
49 'medium' => ['name' => 'Medium', 'limit' => 45000],
50 'threads' => ['name' => 'Threads', 'limit' => 500],
51 'google_business' => ['name' => 'Google Business Profile', 'limit' => 1500],
52 'bluesky' => ['name' => 'Bluesky', 'limit' => 300],
53 'mastodon' => ['name' => 'Mastodon', 'limit' => 500],
54 ];
55
56 /**
57 * Initialize hooks.
58 */
59 private function __construct()
60 {
61 add_action('rest_api_init', array($this, 'register_routes'));
62 }
63
64 /**
65 * Register the AI caption REST route.
66 */
67 public function register_routes()
68 {
69 $namespace = WPSP_PLUGIN_SLUG . '/v1';
70
71 register_rest_route($namespace, 'ai-caption/(?P<post_id>\d+)', array(
72 'methods' => 'POST',
73 'callback' => array($this, 'generate_captions'),
74 'permission_callback' => function () {
75 return current_user_can('edit_posts');
76 },
77 'args' => array(
78 'post_id' => array(
79 'required' => true,
80 'validate_callback' => function ($param) {
81 return is_numeric($param);
82 },
83 ),
84 ),
85 ));
86
87 // Validate an OpenAI API key from the settings panel.
88 register_rest_route($namespace, 'ai-test-key', array(
89 'methods' => 'POST',
90 'callback' => array($this, 'test_connection'),
91 'permission_callback' => function () {
92 return current_user_can('manage_options');
93 },
94 ));
95 }
96
97 /**
98 * Test an OpenAI API key by hitting the models endpoint.
99 *
100 * Accepts an `api_key` param (the value currently typed into the settings
101 * field) and falls back to the saved key when none is supplied.
102 *
103 * @param \WP_REST_Request $request
104 * @return \WP_REST_Response
105 */
106 public function test_connection($request)
107 {
108 $api_key = $request->get_param('api_key');
109 $api_key = is_string($api_key) ? trim($api_key) : '';
110
111 if (empty($api_key)) {
112 $saved = Helper::get_settings('openai_api_key');
113 $api_key = is_string($saved) ? trim($saved) : '';
114 }
115
116 if (empty($api_key)) {
117 return new \WP_REST_Response(array(
118 'success' => false,
119 'message' => __('Please enter an API key before testing the connection.', 'wp-scheduled-posts'),
120 ), 400);
121 }
122
123 $response = wp_remote_get(self::OPENAI_MODELS_ENDPOINT, array(
124 'timeout' => 30,
125 'headers' => array(
126 'Authorization' => 'Bearer ' . $api_key,
127 ),
128 ));
129
130 if (is_wp_error($response)) {
131 return new \WP_REST_Response(array(
132 'success' => false,
133 'message' => $response->get_error_message(),
134 ), 502);
135 }
136
137 $code = wp_remote_retrieve_response_code($response);
138
139 if ($code === 200) {
140 return new \WP_REST_Response(array(
141 'success' => true,
142 'message' => __('Connection successful. Your API key is valid.', 'wp-scheduled-posts'),
143 ), 200);
144 }
145
146 $data = json_decode(wp_remote_retrieve_body($response), true);
147 $message = isset($data['error']['message'])
148 ? $data['error']['message']
149 : __('Connection failed. Please verify your API key and try again.', 'wp-scheduled-posts');
150
151 return new \WP_REST_Response(array(
152 'success' => false,
153 'message' => $message,
154 ), 200);
155 }
156
157 /**
158 * Generate captions for the selected platforms.
159 *
160 * @param \WP_REST_Request $request
161 * @return \WP_REST_Response
162 */
163 public function generate_captions($request)
164 {
165 $post_id = (int) $request->get_param('post_id');
166
167 if (!get_post($post_id) || !current_user_can('edit_post', $post_id)) {
168 return new \WP_REST_Response(array(
169 'success' => false,
170 'message' => __('Post not found or insufficient permissions.', 'wp-scheduled-posts'),
171 ), 403);
172 }
173
174 $api_key = Helper::get_settings('openai_api_key');
175 $api_key = is_string($api_key) ? trim($api_key) : '';
176
177 if (empty($api_key)) {
178 return new \WP_REST_Response(array(
179 'success' => false,
180 'message' => __('OpenAI API key is not configured. Add it in SchedulePress → Settings → AI.', 'wp-scheduled-posts'),
181 ), 400);
182 }
183
184 // Read and sanitize request payload.
185 $platforms = (array) $request->get_param('platforms');
186 $platforms = array_values(array_intersect(
187 array_map('sanitize_key', $platforms),
188 array_keys($this->platform_meta)
189 ));
190
191 if (empty($platforms)) {
192 return new \WP_REST_Response(array(
193 'success' => false,
194 'message' => __('Please select at least one social platform.', 'wp-scheduled-posts'),
195 ), 400);
196 }
197
198 $prompt = sanitize_textarea_field((string) $request->get_param('prompt'));
199 $auto_generate = filter_var($request->get_param('autoGenerate'), FILTER_VALIDATE_BOOLEAN);
200 $tone = sanitize_text_field((string) $request->get_param('tone')) ?: 'professional';
201 $length = sanitize_key((string) $request->get_param('length')) ?: 'auto';
202 $generate_hashtags = filter_var($request->get_param('generateHashtags'), FILTER_VALIDATE_BOOLEAN);
203 $include_emojis = filter_var($request->get_param('includeEmojis'), FILTER_VALIDATE_BOOLEAN);
204
205 // Post context.
206 $post = get_post($post_id);
207 $post_title = get_the_title($post_id);
208 $post_content = wp_strip_all_tags($post->post_content);
209 $post_content = trim(preg_replace('/\s+/', ' ', $post_content));
210 $post_excerpt = function_exists('mb_substr') ? mb_substr($post_content, 0, 1500) : substr($post_content, 0, 1500);
211 $permalink = get_permalink($post_id);
212
213 $messages = $this->build_messages(
214 $platforms,
215 compact('prompt', 'auto_generate', 'tone', 'length', 'generate_hashtags', 'include_emojis'),
216 array(
217 'title' => $post_title,
218 'excerpt' => $post_excerpt,
219 'permalink' => $permalink,
220 )
221 );
222
223 $captions = $this->request_openai($api_key, $messages, $platforms);
224
225 if (is_wp_error($captions)) {
226 return new \WP_REST_Response(array(
227 'success' => false,
228 'message' => $captions->get_error_message(),
229 ), 502);
230 }
231
232 return new \WP_REST_Response(array(
233 'success' => true,
234 'captions' => $captions,
235 ), 200);
236 }
237
238 /**
239 * Build the OpenAI chat messages (system + user).
240 *
241 * @param array $platforms
242 * @param array $opts
243 * @param array $post
244 * @return array
245 */
246 protected function build_messages($platforms, $opts, $post)
247 {
248 // Character ranges the AI generates to. Keep these in sync with
249 // LENGTH_OPTIONS in src/components/modals/socialTemplates/AICaptionDrawer.js.
250 $length_guide = array(
251 'auto' => 'an appropriate length for each platform',
252 'short' => 'short and punchy — roughly 50-120 characters (1-2 sentences)',
253 'medium' => 'a moderate length — roughly 120-250 characters (2-4 sentences)',
254 'long' => 'detailed and engaging — roughly 250-500 characters (multiple sentences)',
255 );
256 $length_text = isset($length_guide[$opts['length']]) ? $length_guide[$opts['length']] : $length_guide['auto'];
257
258 // Per-platform constraints. Use the limits configured in settings (the
259 // same ones template validation enforces) so generated captions fit.
260 $limits = Helper::get_social_platform_limits();
261 $platform_lines = array();
262 foreach ($platforms as $key) {
263 $meta = $this->platform_meta[$key];
264 $limit = isset($limits[$key]) ? $limits[$key] : $meta['limit'];
265 $platform_lines[] = sprintf('- "%s" (%s, max %d characters — never exceed this)', $key, $meta['name'], $limit);
266 }
267 $platform_block = implode("\n", $platform_lines);
268 $keys_list = '"' . implode('", "', $platforms) . '"';
269
270 $system = 'You are an expert social media copywriter. You write engaging, platform-native captions that drive engagement. '
271 . 'You always respond with a single valid JSON object and nothing else — no markdown, no code fences, no commentary.';
272
273 $user = "Write a social media caption for each of the following platforms, tailored to each platform's style and character limit:\n";
274 $user .= $platform_block . "\n\n";
275 $user .= "Post title: " . $post['title'] . "\n";
276 if (!empty($post['excerpt'])) {
277 $user .= "Post content: " . $post['excerpt'] . "\n";
278 }
279 if (!empty($post['permalink'])) {
280 $user .= "Post URL: " . $post['permalink'] . "\n";
281 }
282 $user .= "\nRequirements:\n";
283 if ($opts['tone'] === 'post_specific') {
284 // Match the post's own voice instead of applying a fixed preset.
285 $user .= "- Tone/style: analyze the tone, voice, and writing style of the post title and content above, then write each caption to match that same tone and style.\n";
286 } else {
287 $user .= "- Tone/style: {$opts['tone']}.\n";
288 }
289 $user .= "- Length: {$length_text}.\n";
290 $user .= '- Hashtags: ' . ($opts['generate_hashtags'] ? 'include a few relevant hashtags.' : 'do not include hashtags.') . "\n";
291 $user .= '- Emojis: ' . ($opts['include_emojis'] ? 'use tasteful, relevant emojis.' : 'do not use emojis.') . "\n";
292
293 if (!empty($opts['prompt'])) {
294 $user .= "- Extra instructions from the user: {$opts['prompt']}\n";
295 } elseif (!empty($opts['auto_generate'])) {
296 $user .= "- Base the captions on the post title and content above.\n";
297 }
298
299 $user .= "\nReturn a JSON object whose keys are exactly {$keys_list} and whose values are the caption strings. Respect each platform's character limit.";
300
301 return array(
302 array('role' => 'system', 'content' => $system),
303 array('role' => 'user', 'content' => $user),
304 );
305 }
306
307 /**
308 * Call the OpenAI API and parse captions out of the response.
309 *
310 * @param string $api_key
311 * @param array $messages
312 * @param array $platforms
313 * @return array|\WP_Error Map of platform => caption, or WP_Error on failure.
314 */
315 protected function request_openai($api_key, $messages, $platforms)
316 {
317 $model = apply_filters('wpsp_openai_model', 'gpt-4o-mini');
318
319 $body = array(
320 'model' => $model,
321 'messages' => $messages,
322 'temperature' => 0.7,
323 'response_format' => array('type' => 'json_object'),
324 );
325
326 $response = wp_remote_post(self::OPENAI_ENDPOINT, array(
327 'timeout' => 60,
328 'headers' => array(
329 'Content-Type' => 'application/json',
330 'Authorization' => 'Bearer ' . $api_key,
331 ),
332 'body' => wp_json_encode($body),
333 ));
334
335 if (is_wp_error($response)) {
336 return new \WP_Error('wpsp_openai_request_failed', $response->get_error_message());
337 }
338
339 $code = wp_remote_retrieve_response_code($response);
340 $raw = wp_remote_retrieve_body($response);
341 $data = json_decode($raw, true);
342
343 if ($code !== 200) {
344 $message = isset($data['error']['message'])
345 ? $data['error']['message']
346 : __('OpenAI request failed. Please verify your API key and try again.', 'wp-scheduled-posts');
347 return new \WP_Error('wpsp_openai_error', $message);
348 }
349
350 $content = isset($data['choices'][0]['message']['content'])
351 ? $data['choices'][0]['message']['content']
352 : '';
353
354 if (empty($content)) {
355 return new \WP_Error('wpsp_openai_empty', __('OpenAI returned an empty response.', 'wp-scheduled-posts'));
356 }
357
358 $parsed = json_decode($content, true);
359
360 // Fallback: if the model didn't return clean JSON, apply the text to every platform.
361 if (!is_array($parsed)) {
362 $text = trim($content);
363 $parsed = array();
364 foreach ($platforms as $key) {
365 $parsed[$key] = $text;
366 }
367 }
368
369 // Keep only requested platforms and coerce to trimmed strings.
370 // Hard-trim to each platform's character limit so the generated caption
371 // always passes template validation when the user saves it.
372 $limits = Helper::get_social_platform_limits();
373 $captions = array();
374 foreach ($platforms as $key) {
375 if (isset($parsed[$key])) {
376 $caption = is_string($parsed[$key]) ? trim($parsed[$key]) : trim(wp_json_encode($parsed[$key]));
377 $limit = isset($limits[$key]) ? $limits[$key] : 0;
378 $captions[$key] = $this->enforce_limit($caption, $limit);
379 }
380 }
381
382 if (empty($captions)) {
383 return new \WP_Error('wpsp_openai_unparsable', __('Could not parse captions from the AI response.', 'wp-scheduled-posts'));
384 }
385
386 return $captions;
387 }
388
389 /**
390 * Trim a caption to a platform character limit without cutting mid-word.
391 *
392 * Counts characters (not bytes) so emojis are handled correctly, and backs
393 * up to the last whitespace when possible to avoid a chopped word.
394 *
395 * @param string $text
396 * @param int $limit Character limit; 0 or less means "no limit".
397 * @return string
398 */
399 protected function enforce_limit($text, $limit)
400 {
401 $limit = (int) $limit;
402 $length = function_exists('mb_strlen') ? mb_strlen($text) : strlen($text);
403
404 if ($limit <= 0 || $length <= $limit) {
405 return $text;
406 }
407
408 $cut = function_exists('mb_substr') ? mb_substr($text, 0, $limit) : substr($text, 0, $limit);
409 $space = function_exists('mb_strrpos') ? mb_strrpos($cut, ' ') : strrpos($cut, ' ');
410
411 // Only honour the word boundary if it does not discard too much text.
412 if ($space !== false && $space > (int) ($limit * 0.6)) {
413 $cut = function_exists('mb_substr') ? mb_substr($cut, 0, $space) : substr($cut, 0, $space);
414 }
415
416 return rtrim($cut);
417 }
418
419 /**
420 * Return an instance of this class.
421 *
422 * @return object
423 */
424 public static function get_instance()
425 {
426 if (null == self::$instance) {
427 self::$instance = new self;
428 }
429
430 return self::$instance;
431 }
432 }
433