| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\FullSiteImport\Utils\Providers; |
| 4 |
|
| 5 |
use Templately\Modules\FullSiteImport\Utils\AIContentResolver; |
| 6 |
use Templately\Modules\FullSiteImport\Utils\AIUtils; |
| 7 |
use Templately\Utils\Helper; |
| 8 |
|
| 9 |
/** |
| 10 |
* Built-in AI content provider for the CHAT-ID generation flow (Phase 2 thin |
| 11 |
* import — content generated on the backend and pulled by conversation uuid). |
| 12 |
* |
| 13 |
* Unlike the classic flow there is no per-page callback: the content lives on |
| 14 |
* the backend and must be pulled. This provider pulls the ready pages via |
| 15 |
* `v2/chatbot/generated/{chat}` and writes each to its `.ai.json` using the same |
| 16 |
* {@see AIUtils::save_template_to_file()} the classic callback uses — so the |
| 17 |
* wait/merge downstream is byte-for-byte the classic path. |
| 18 |
* |
| 19 |
* The wait now happens at the END (inside the Finalizer, through the resolver) |
| 20 |
* instead of up front: the import starts immediately and each page is pulled |
| 21 |
* on demand as finalize reaches it, mirroring the classic AI flow. |
| 22 |
* |
| 23 |
* A process is owned by this provider when its stored process data carries a |
| 24 |
* `chat_id` (set when the chat import registers its process). Registered on |
| 25 |
* {@see AIContentResolver::STAGE_HOOK}. |
| 26 |
*/ |
| 27 |
class ChatAIContentProvider { |
| 28 |
|
| 29 |
/** |
| 30 |
* Backend `App\Enums\ChatbotGeneratedReadiness::READY` — generation for the |
| 31 |
* conversation has finished (PENDING = 0). |
| 32 |
*/ |
| 33 |
const READINESS_READY = 1; |
| 34 |
|
| 35 |
/** |
| 36 |
* @param bool $staged True if a previous provider already produced the file. |
| 37 |
* @param array $context Resolver context (see AIContentResolver::ensure_page_ready). |
| 38 |
* @return bool |
| 39 |
*/ |
| 40 |
public static function stage( $staged, array $context ) { |
| 41 |
if ( $staged === true ) { |
| 42 |
return true; |
| 43 |
} |
| 44 |
|
| 45 |
$process_data = $context['process_data'] ?? []; |
| 46 |
$chat_id = $process_data['chat_id'] ?? ''; |
| 47 |
|
| 48 |
// Not a chat-sourced import — let another provider handle it. |
| 49 |
if ( empty( $chat_id ) ) { |
| 50 |
return $staged; |
| 51 |
} |
| 52 |
|
| 53 |
$session_id = $context['session_id'] ?? ''; |
| 54 |
$process_id = $context['process_id'] ?? null; |
| 55 |
$ai_page_ids = $context['ai_page_ids'] ?? []; |
| 56 |
|
| 57 |
if ( empty( $process_id ) || empty( $session_id ) || empty( $ai_page_ids ) ) { |
| 58 |
return $staged; |
| 59 |
} |
| 60 |
|
| 61 |
// The api_key `chatbot_import_prepare` banked on the process data. It is |
| 62 |
// REQUIRED here: prepare runs as a REST call authenticated by the browser's |
| 63 |
// `X-Templately-Apikey` header (API\AIContent overrides _permission_check), |
| 64 |
// but the Finalizer runs in the admin-ajax/SSE import where no such header |
| 65 |
// exists and `Options::get('api_key')` can be empty or user-scoped to a |
| 66 |
// different user. Without it every pull here comes back 401 and the wait |
| 67 |
// spins to its timeout. |
| 68 |
$api_key = $process_data['api_key'] ?? ''; |
| 69 |
|
| 70 |
self::pull_generated( $chat_id, $process_id, $session_id, $ai_page_ids, $api_key ); |
| 71 |
|
| 72 |
return AIContentResolver::page_staged( $context ) ? true : $staged; |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Run a callback with the Templately API Bearer forced to `$api_key`. |
| 77 |
* |
| 78 |
* `Helper::make_api_request()` reads `Options::get('api_key')` for the |
| 79 |
* Authorization header, which is not reliably populated in the import |
| 80 |
* context — so override it through the same `templately_api_request_params` |
| 81 |
* filter the request already applies, and always unhook afterwards. |
| 82 |
* |
| 83 |
* @param string $api_key Key to authenticate with (no-op when empty). |
| 84 |
* @param callable $callback Receives no arguments; its return value is returned. |
| 85 |
* @return mixed |
| 86 |
*/ |
| 87 |
private static function with_api_key( $api_key, callable $callback ) { |
| 88 |
if ( empty( $api_key ) ) { |
| 89 |
return $callback(); |
| 90 |
} |
| 91 |
|
| 92 |
$override = function ( $args ) use ( $api_key ) { |
| 93 |
$args['headers']['Authorization'] = 'Bearer ' . $api_key; |
| 94 |
return $args; |
| 95 |
}; |
| 96 |
|
| 97 |
add_filter( 'templately_api_request_params', $override, 999 ); |
| 98 |
try { |
| 99 |
return $callback(); |
| 100 |
} finally { |
| 101 |
remove_filter( 'templately_api_request_params', $override, 999 ); |
| 102 |
} |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Pull every currently-generated page for a conversation and write each to |
| 107 |
* its `.ai.json` (plus explicit skip markers), recording credits consumed. |
| 108 |
* |
| 109 |
* Idempotent and safe to call on each finalize wait tick: re-writing an |
| 110 |
* already-present page is harmless, and pages still generating simply have |
| 111 |
* not appeared yet. This is the chat twin of |
| 112 |
* {@see AIUtils::poll_for_template()}. |
| 113 |
* |
| 114 |
* @param string $chat_id Conversation uuid. |
| 115 |
* @param string $process_id AI process id (keys processed-pages data). |
| 116 |
* @param string $session_id Import session id. |
| 117 |
* @param array $ai_page_ids `type/sub_type => [content_id,...]` map. |
| 118 |
* @return bool True when the pull succeeded (regardless of how many pages were ready). |
| 119 |
*/ |
| 120 |
public static function pull_generated( $chat_id, $process_id, $session_id, $ai_page_ids, $api_key = '' ): bool { |
| 121 |
$response = self::with_api_key( $api_key, function () use ( $chat_id ) { |
| 122 |
return Helper::make_api_get_request( "v2/chatbot/generated/{$chat_id}", [], [ 'Accept' => 'application/json' ], 30 ); |
| 123 |
} ); |
| 124 |
|
| 125 |
if ( is_wp_error( $response ) ) { |
| 126 |
Helper::log( sprintf( 'chat_provider[%s] pull failed: %s', $chat_id, $response->get_error_message() ), 'ai-import', 'error' ); |
| 127 |
return false; |
| 128 |
} |
| 129 |
|
| 130 |
$response_code = wp_remote_retrieve_response_code( $response ); |
| 131 |
if ( $response_code !== 200 ) { |
| 132 |
Helper::log( sprintf( 'chat_provider[%s] pull HTTP %d', $chat_id, $response_code ), 'ai-import', 'error' ); |
| 133 |
return false; |
| 134 |
} |
| 135 |
|
| 136 |
$data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 137 |
|
| 138 |
// The chatbot API signals application-level failure INSIDE an HTTP 200 as |
| 139 |
// `{status:'error', message}` — the same contract chatbot_request() in |
| 140 |
// ai-fsi's REST\AIContent enforces. This call site cannot use that seam |
| 141 |
// (different module, and the Finalizer context needs the raw-transport |
| 142 |
// with_api_key override), so the rule is enforced inline: an error body is |
| 143 |
// a FAILED pull, never "0 pages ready". Without this the error fell |
| 144 |
// through to the normal wrote=0 info line and the wait ran to its 420s |
| 145 |
// timeout with nothing in the log naming the actual cause. |
| 146 |
if ( ! is_array( $data ) || 'error' === ( $data['status'] ?? '' ) ) { |
| 147 |
$message = is_array( $data ) && ! empty( $data['message'] ) ? $data['message'] : 'invalid response body'; |
| 148 |
Helper::log( sprintf( 'chat_provider[%s] pull rejected: %s', $chat_id, $message ), 'ai-import', 'error' ); |
| 149 |
return false; |
| 150 |
} |
| 151 |
|
| 152 |
$generated = ( isset( $data['data'] ) && is_array( $data['data'] ) ) ? $data['data'] : []; |
| 153 |
|
| 154 |
// Access gate — mirrors the chatbot-import-prepare endpoint. A free user |
| 155 |
// past their 7-day window must not have content pulled mid-import. |
| 156 |
if ( isset( $generated['can_import'] ) && ! $generated['can_import'] ) { |
| 157 |
Helper::log( sprintf( 'chat_provider[%s] can_import=false, skipping pull', $chat_id ), 'ai-import', 'error' ); |
| 158 |
return false; |
| 159 |
} |
| 160 |
|
| 161 |
$templates = ( isset( $generated['templates'] ) && is_array( $generated['templates'] ) ) ? $generated['templates'] : []; |
| 162 |
$skipped = ( isset( $generated['skipped_pages'] ) && is_array( $generated['skipped_pages'] ) ) ? array_map( 'strval', $generated['skipped_pages'] ) : []; |
| 163 |
|
| 164 |
// Write each newly-generated page to its .ai.json location for the runners. |
| 165 |
// Pages already on disk are skipped — this runs on every finalize tick and |
| 166 |
// re-serialising the whole bundle each time is pure waste. |
| 167 |
$written = 0; |
| 168 |
foreach ( $templates as $content_id => $template ) { |
| 169 |
if ( empty( $template ) ) { |
| 170 |
continue; |
| 171 |
} |
| 172 |
if ( self::page_file_exists( $session_id, $content_id, $ai_page_ids ) ) { |
| 173 |
continue; |
| 174 |
} |
| 175 |
// The runners read JSON strings; normalize arrays/objects to a string. |
| 176 |
$payload = is_string( $template ) ? $template : wp_json_encode( $template ); |
| 177 |
AIUtils::save_template_to_file( $process_id, $session_id, $content_id, $payload, $ai_page_ids, false ); |
| 178 |
$written++; |
| 179 |
} |
| 180 |
|
| 181 |
// Backend-skipped pages → explicit `{"isSkipped":true}` markers so finalize |
| 182 |
// falls back to the pack's default content instead of waiting forever. |
| 183 |
foreach ( $skipped as $skipped_id ) { |
| 184 |
if ( array_key_exists( $skipped_id, $templates ) || array_key_exists( (int) $skipped_id, $templates ) ) { |
| 185 |
continue; |
| 186 |
} |
| 187 |
if ( self::page_file_exists( $session_id, $skipped_id, $ai_page_ids ) ) { |
| 188 |
continue; |
| 189 |
} |
| 190 |
AIUtils::save_template_to_file( $process_id, $session_id, $skipped_id, '', $ai_page_ids, true ); |
| 191 |
$written++; |
| 192 |
} |
| 193 |
|
| 194 |
$expected = AIUtils::flatten_ai_page_ids( $ai_page_ids ); |
| 195 |
$missing = array_values( array_filter( $expected, function ( $id ) use ( $templates, $skipped ) { |
| 196 |
return ! array_key_exists( $id, $templates ) |
| 197 |
&& ! array_key_exists( (int) $id, $templates ) |
| 198 |
&& ! in_array( (string) $id, $skipped, true ); |
| 199 |
} ) ); |
| 200 |
|
| 201 |
// Generation is FINISHED but some expected pages were never delivered and |
| 202 |
// were never listed in `skipped_pages` either — the backend silently did |
| 203 |
// not produce them (seen live: a pack's "Product Roadmap" page absent from |
| 204 |
// a `status: READY` bundle with `skipped_pages: []`). |
| 205 |
// |
| 206 |
// Nothing will ever arrive for these, so treat them as skipped and write |
| 207 |
// the marker now. Without this the Finalizer waits the full 420s on each |
| 208 |
// one before falling back to the pack's default content anyway — a |
| 209 |
// multi-minute stall with a frozen progress bar for an identical result. |
| 210 |
if ( ! empty( $missing ) && self::is_generation_finished( $generated ) ) { |
| 211 |
foreach ( $missing as $undelivered_id ) { |
| 212 |
if ( self::page_file_exists( $session_id, $undelivered_id, $ai_page_ids ) ) { |
| 213 |
continue; |
| 214 |
} |
| 215 |
AIUtils::save_template_to_file( $process_id, $session_id, $undelivered_id, '', $ai_page_ids, true ); |
| 216 |
$written++; |
| 217 |
} |
| 218 |
Helper::log( sprintf( |
| 219 |
'chat_provider[%s] generation finished but %d page(s) never delivered (%s) — marked skipped, importing pack defaults', |
| 220 |
$chat_id, count( $missing ), implode( ',', $missing ) |
| 221 |
), 'ai-import', 'error' ); |
| 222 |
$missing = []; |
| 223 |
} |
| 224 |
|
| 225 |
// Record credits consumed ONLY once generation is genuinely complete. |
| 226 |
// |
| 227 |
// `credit_cost` is the pipeline's "generation finished, stop waiting" |
| 228 |
// signal: its mere presence makes handle_sse_wait_with_timeout() return |
| 229 |
// true immediately. The backend reports `credits_consumed` incrementally, |
| 230 |
// so writing it on the first pull would disable waiting for the entire |
| 231 |
// import and every still-generating page would fall back to pack defaults. |
| 232 |
$credit_cost = $generated['credits_consumed'] ?? ( $generated['credit_cost'] ?? 0 ); |
| 233 |
if ( empty( $missing ) && $credit_cost > 0 ) { |
| 234 |
$processed_pages = get_option( 'templately_ai_processed_pages', [] ); |
| 235 |
$processed_pages[ $process_id ] = $processed_pages[ $process_id ] ?? []; |
| 236 |
$processed_pages[ $process_id ]['credit_cost'] = $credit_cost; |
| 237 |
update_option( 'templately_ai_processed_pages', $processed_pages, false ); |
| 238 |
} |
| 239 |
|
| 240 |
Helper::log( sprintf( |
| 241 |
'chat_provider[%s] pull: wrote=%d ready=%d/%d missing=%d skipped=%d', |
| 242 |
$chat_id, $written, count( $expected ) - count( $missing ), count( $expected ), count( $missing ), count( $skipped ) |
| 243 |
), 'ai-import', 'info' ); |
| 244 |
|
| 245 |
return true; |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* Whether the backend considers generation for this conversation FINISHED. |
| 250 |
* |
| 251 |
* Mirrors the frontend's `GENERATED_READINESS_READY` (see |
| 252 |
* AiContentSidebar/AIConversation.js — backend enum |
| 253 |
* `App\Enums\ChatbotGeneratedReadiness`: PENDING = 0, READY = 1). Once READY, |
| 254 |
* a page absent from both `templates` and `skipped_pages` is never coming. |
| 255 |
* |
| 256 |
* Deliberately strict: an unknown/missing `status` is treated as NOT finished, |
| 257 |
* so an unrecognised payload makes us keep waiting rather than prematurely |
| 258 |
* degrade pages to pack default content. |
| 259 |
* |
| 260 |
* @param array $generated The bundle's `data` payload. |
| 261 |
* @return bool |
| 262 |
*/ |
| 263 |
private static function is_generation_finished( array $generated ): bool { |
| 264 |
return isset( $generated['status'] ) && (int) $generated['status'] === self::READINESS_READY; |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* Whether a page's `.ai.json` is already on disk, so the pull can skip |
| 269 |
* rewriting it. Thin wrapper over the resolver's staged check. |
| 270 |
* |
| 271 |
* @param string $session_id |
| 272 |
* @param string|int $content_id |
| 273 |
* @param array $ai_page_ids |
| 274 |
* @return bool |
| 275 |
*/ |
| 276 |
private static function page_file_exists( $session_id, $content_id, $ai_page_ids ): bool { |
| 277 |
return AIContentResolver::page_staged( [ |
| 278 |
'session_id' => $session_id, |
| 279 |
'content_id' => $content_id, |
| 280 |
'ai_page_ids' => $ai_page_ids, |
| 281 |
] ); |
| 282 |
} |
| 283 |
} |
| 284 |
|