register_strategist_routes(); * * Plus the small settings patch (business_context / target_keywords / serp_gl) * added to save_settings()'s update_option array — see the README note. * * Endpoints (all gated by current_user_can('edit_posts')): * POST /aicobr/v1/strategist/preview → cost preview (no charge) * POST /aicobr/v1/strategist/plan → research + plan (charges credits) * POST /aicobr/v1/strategist/populate → write approved items as scheduled drafts */ if (!defined('ABSPATH')) exit; trait AICOBR_Strategist_Trait { // How far back to dedupe against the live site (spec §12 — Ibbz: last 12 months). private static $AICOBR_STRATEGIST_LIVE_DEDUPE_MONTHS = 12; private static $AICOBR_STRATEGIST_LIVE_DEDUPE_CAP = 500; // bound the WP_Query on big sites public function register_strategist_routes() { $this->aicobr_register_route('/strategist/preview', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_preview'], 'permission_callback' => fn() => current_user_can('edit_posts'), ]); $this->aicobr_register_route('/strategist/plan', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_plan'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'strategist_plan')), ]); $this->aicobr_register_route('/strategist/populate', [ 'methods' => 'POST', 'callback' => [$this, 'strategist_populate'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'strategist_populate')), ]); } // ─── sanitisers ────────────────────────────────────────────────────────── /** Normalise a string for dedupe — mirrors normaliseKey() on the licence server. */ private function aicobr_normalise_key($s) { $s = strtolower((string) $s); $s = preg_replace('/[^a-z0-9]+/', ' ', $s); return trim($s); } /** Recursively sanitise a scalar/array blob before it goes into wp_options. */ private function aicobr_sanitise_meta($v) { if (is_array($v)) { $out = []; foreach ($v as $k => $val) { $out[sanitize_key((string) $k)] = $this->aicobr_sanitise_meta($val); } return $out; } if (is_bool($v) || is_int($v) || is_float($v) || $v === null) return $v; return sanitize_text_field((string) $v); } /** Pull the cadence object out of a request, sanitised into the two primitives. */ private function aicobr_sanitise_cadence($c) { $c = is_array($c) ? $c : []; $mode = ($c['mode'] ?? 'weekday') === 'interval' ? 'interval' : 'weekday'; return [ 'mode' => $mode, 'interval_days' => max(1, (int) ($c['interval_days'] ?? 7)), 'weekdays' => array_values(array_filter(array_map( fn($n) => max(0, min(6, (int) $n)), is_array($c['weekdays'] ?? null) ? $c['weekdays'] : [] ), fn($n) => $n !== null)), 'week_stride' => max(1, (int) ($c['week_stride'] ?? 1)), ]; } /** Plan parameters block, sanitised. */ private function aicobr_sanitise_params($p) { $p = is_array($p) ? $p : []; return [ 'timespan_months' => max(1, (int) ($p['timespan_months'] ?? 3)), 'cadence' => $this->aicobr_sanitise_cadence($p['cadence'] ?? []), 'content_types' => array_values(array_filter(array_map( 'sanitize_text_field', is_array($p['content_types'] ?? null) ? $p['content_types'] : [] ))), 'start_date' => sanitize_text_field($p['start_date'] ?? ''), ]; } // ─── dedupe inputs ───────────────────────────────────────────────────────── /** * Build the exclusion list the licence server uses so the planner never * re-suggests something already in the diary or already published live. * Titles + focus keywords; the server normalises both sides. */ private function aicobr_strategist_exclude_list() { $titles = []; $kws = []; // 1. Content Diary (planned + generated entries). $entries = get_option('aicobr_entries', []); if (is_array($entries)) { foreach ($entries as $e) { if (!empty($e['title'])) $titles[] = (string) $e['title']; if (!empty($e['primary_keyword'])) $kws[] = (string) $e['primary_keyword']; if (!empty($e['seo_focus_keyword'])) $kws[] = (string) $e['seo_focus_keyword']; } } // 2. Recently published content on the live site (bounded). $q = new WP_Query([ 'post_type' => ['post', 'page'], 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => self::$AICOBR_STRATEGIST_LIVE_DEDUPE_CAP, 'no_found_rows' => true, 'date_query' => [[ 'after' => self::$AICOBR_STRATEGIST_LIVE_DEDUPE_MONTHS . ' months ago', 'inclusive' => true, ]], ]); foreach ($q->posts as $pid) { $t = get_the_title($pid); if ($t) $titles[] = $t; } wp_reset_postdata(); return [ 'titles' => array_values(array_unique($titles)), 'focus_keywords' => array_values(array_unique($kws)), ]; } /** Latest scheduled/planned date in the diary, or '' — used to top up a re-run AFTER what exists. */ private function aicobr_strategist_latest_planned_date() { $entries = get_option('aicobr_entries', []); $latest = ''; if (is_array($entries)) { foreach ($entries as $e) { $d = (string) ($e['publish_date'] ?? ''); if ($d && $d > $latest) $latest = $d; } } return $latest; } // ─── proxy: cost preview (no charge, no AI) ──────────────────────────────── public function strategist_preview($request) { $data = $request->get_json_params() ?: []; $settings = get_option('aicobr_settings', []); $payload = [ 'license_key' => $settings['license_key'] ?? '', 'params' => $this->aicobr_sanitise_params($data['params'] ?? []), ]; return $this->aicobr_strategist_proxy('/api/strategist/preview', $payload, 20); } // ─── proxy: the real plan run (charges credits server-side) ──────────────── public function strategist_plan($request) { $data = $request->get_json_params() ?: []; $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]); } // Business context: prefer the persisted settings (filled once); allow the // request to override on the very first run before it's been saved. $bc_saved = is_array($settings['business_context'] ?? null) ? $settings['business_context'] : []; $bc_req = is_array($data['business_context'] ?? null) ? $data['business_context'] : []; $business = [ 'what' => sanitize_text_field($bc_req['what'] ?? ($bc_saved['what'] ?? '')), 'industry' => sanitize_text_field($bc_req['industry'] ?? ($bc_saved['industry'] ?? '')), 'location' => sanitize_text_field($bc_req['location'] ?? ($bc_saved['location'] ?? '')), ]; if (empty($business['location'])) { return new WP_Error('no_location', 'Add your business location before running a plan.', ['status' => 400]); } // Seeds: persisted target keywords + any "for this run" additions. $persisted = is_array($settings['target_keywords'] ?? null) ? $settings['target_keywords'] : []; $run_add = is_array($data['seed_keywords'] ?? null) ? $data['seed_keywords'] : []; $seeds = array_values(array_unique(array_filter(array_map( 'sanitize_text_field', array_merge($persisted, $run_add) )))); if (empty($seeds)) { return new WP_Error('no_seeds', 'Add at least one target keyword first.', ['status' => 400]); } $params = $this->aicobr_sanitise_params($data['params'] ?? []); // Re-run convenience: if no explicit start was chosen and the diary already // holds planned dates, start the new plan the day AFTER the latest one so a // top-up lands around (not on top of) what's already scheduled. if (empty($params['start_date'])) { $latest = $this->aicobr_strategist_latest_planned_date(); if ($latest) { $next = gmdate('Y-m-d', strtotime($latest . ' +1 day')); if ($next) $params['start_date'] = $next; } } $payload = [ 'license_key' => $license_key, 'business_context' => $business, 'seed_keywords' => $seeds, 'params' => $params, 'exclude' => $this->aicobr_strategist_exclude_list(), ]; if (!empty($settings['serp_gl'])) { $payload['serp_gl'] = sanitize_text_field($settings['serp_gl']); } // Planning runs a Serper fan-out + a Claude pass — give it room. return $this->aicobr_strategist_proxy('/api/strategist/plan', $payload, 120); } /** Shared proxy: POST payload to the licence server with the shared secret. */ private function aicobr_strategist_proxy($path, $payload, $timeout = 60) { $url = self::get_server_url() . $path; $response = wp_remote_post($url, [ 'timeout' => $timeout, 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => self::get_secret(), ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] strategist proxy failed: ' . $response->get_error_message()); return new WP_Error('server_unreachable', 'Could not reach the Strategist server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $payload_out = ['status' => $code]; // Pass through the server's credits_remaining so the UI can show it on a 402. if (isset($body['credits_remaining'])) $payload_out['credits_remaining'] = $body['credits_remaining']; return new WP_Error('strategist_failed', $body['error'] ?? 'Strategist request failed.', $payload_out); } return rest_ensure_response($body); } // ─── write approved items into the Content Diary as scheduled drafts ────── /** * Takes the approved (and possibly user-edited) plan items and appends them * to aicobr_entries as `scheduled` entries. Defensive dedupe at write * time (the plan was already deduped at planning, but the diary can change in * between): skip an item whose date is already taken, or whose normalised * title / focus keyword already exists. Reuses the entries lock. */ public function strategist_populate($request) { $data = $request->get_json_params() ?: []; $items = is_array($data['items'] ?? null) ? $data['items'] : []; if (empty($items)) { return new WP_Error('no_items', 'No plan items to add.', ['status' => 400]); } $settings = get_option('aicobr_settings', []); $default_wc = (int) ($settings['default_word_count'] ?? 1500) ?: 1500; $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); if (!is_array($entries)) $entries = []; // Existing-state sets for dedupe. $takenDates = []; $seenTitle = []; $seenKw = []; foreach ($entries as $e) { if (!empty($e['publish_date'])) $takenDates[(string) $e['publish_date']] = true; if (!empty($e['title'])) $seenTitle[$this->aicobr_normalise_key($e['title'])] = true; if (!empty($e['primary_keyword'])) $seenKw[$this->aicobr_normalise_key($e['primary_keyword'])] = true; if (!empty($e['seo_focus_keyword'])) $seenKw[$this->aicobr_normalise_key($e['seo_focus_keyword'])] = true; } $base_id = (int) round(microtime(true) * 1000); $added = 0; $skipped = []; $i = 0; foreach ($items as $item) { $i++; $title = sanitize_text_field($item['title'] ?? ''); $focus = sanitize_text_field($item['focus_keyphrase'] ?? ($item['primary_keyword'] ?? '')); $date = sanitize_text_field($item['suggested_date'] ?? ($item['publish_date'] ?? '')); $type = sanitize_text_field($item['content_type'] ?? 'blog_post'); // Date must look like YYYY-MM-DD. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $skipped[] = ['title' => $title, 'reason' => 'bad_date']; continue; } if (empty($title)) { $skipped[] = ['title' => '(untitled)', 'reason' => 'no_title']; continue; } $nt = $this->aicobr_normalise_key($title); $nk = $this->aicobr_normalise_key($focus); if (isset($takenDates[$date])) { $skipped[] = ['title' => $title, 'reason' => 'date_taken']; continue; } if ($nt && isset($seenTitle[$nt])) { $skipped[] = ['title' => $title, 'reason' => 'dup_title']; continue; } if ($nk && isset($seenKw[$nk])) { $skipped[] = ['title' => $title, 'reason' => 'dup_keyword']; continue; } $entries[] = [ 'id' => $base_id + $i, 'title' => $title, 'primary_keyword' => $focus, 'seo_focus_keyword' => $focus, 'content_type' => $type, // ACB_PAGE_TYPES_DEFAULT_2026_07_21: page-natured types publish to a // WordPress page; everything else in the Strategist palette is a post. 'post_type' => in_array($type, ['landing_page', 'service_page', 'about_us', 'faq_page'], true) ? 'page' : 'post', 'publish_date' => $date, 'status' => 'scheduled', 'target_word_count' => $default_wc, 'special_instructions'=> sanitize_textarea_field($item['special_instructions'] ?? ''), 'content_type_meta' => $this->aicobr_sanitise_meta($item['content_type_meta'] ?? []), 'include_image' => true, // Integration fields default to match a manually-added entry. 'mailpoet_list_id' => 0, 'tnp_list_id' => 0, 'tec_start_date' => '', 'tec_end_date' => '', 'tec_cost' => '', 'tec_url' => '', 'tec_venue' => '', 'tec_organiser' => '', 'lms_type' => '', 'lms_course_id' => 0, 'lms_lesson_id' => 0, 'lms_section_id' => 0, 'ld_post_type' => '', 'source' => 'strategist', 'created_at' => current_time('mysql'), ]; // Mark consumed so the same batch can't add two items to one date/topic. $takenDates[$date] = true; if ($nt) $seenTitle[$nt] = true; if ($nk) $seenKw[$nk] = true; $added++; } update_option('aicobr_entries', $entries); if ($locked) $this->aicobr_unlock_entries(); return rest_ensure_response([ 'success' => true, 'added' => $added, 'skipped' => $skipped, ]); } }