diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b04ec15..f95d04a 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -533,8 +533,8 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month. -### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway) -The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside. +### #16: Image generation via Cloudflare Workers AI (FLUX.1), via AI Gateway (v0.8.2+) +The image-generation layer uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. **v0.8.2+**: calls route through the Cloudflare AI Gateway (same gateway we already use for OpenRouter — `gateway.ai.cloudflare.com/v1/{account}/{gateway}/workers-ai/@cf/black-forest-labs/...`) for response caching, unified cost/latency telemetry, and rate limiting. The 2026-04-15 regression that caused the gateway route to 403 on Workers AI has been resolved upstream; verified by direct probe on 2026-04-21. A `prautoblogger_cf_image_via_gateway` toggle (default on) falls back to the direct `api.cloudflare.com/client/v4/accounts/{id}/ai/run/...` URL if the gateway regresses again. The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so switching to a different image API (DALL-E, Replicate) is a new class implementation plus a one-line swap in the pipeline wiring. ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. diff --git a/CHANGELOG.md b/CHANGELOG.md index 28afb2c..c45daaf 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.8.2] — 2026-04-21 + +### Fixed + +- **Daily generation schedule now honors site timezone.** Previously + the time configured in Settings → Schedule was interpreted as UTC + (WordPress core forces `date_default_timezone_set('UTC')`), causing + runs to fire N hours off from user intent where N is the site's UTC + offset. On peptiderepo.com (Asia/Singapore, UTC+8), a `schedule_time` + of `06:00` fired at 14:00 local. An on-upgrade one-shot migration + (`prautoblogger_migrated_schedule_tz_v082`) reschedules the existing + daily cron in the site's configured timezone — no user action + required. + +### Changed + +- **Cloudflare Workers AI image calls now route through the AI Gateway + by default.** The 2026-04-15 regression that caused the gateway route + to 403 on Workers AI has been resolved upstream. Routing via the + gateway gives us response caching, unified cost/latency dashboard, + and rate limiting for free, mirroring the OpenRouter LLM path. A new + admin toggle `prautoblogger_cf_image_via_gateway` (default on) lets + operators force direct-API if the gateway route regresses again, + without clearing the gateway URL setting the LLM provider depends on. +- **"Generation Time" settings description** now names the site's + configured timezone explicitly (from `wp_timezone_string()`) instead + of the ambiguous "server timezone" label — operators can see at a + glance which zone their input is being interpreted in. + ## [0.8.1] — 2026-04-21 ### Fixed diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 673a21b..6b30235 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -95,7 +95,11 @@ class PRAutoBlogger_Settings_Fields_Extended { 'type' => 'time', 'section' => 'prautoblogger_schedule', 'default' => '03:00', - 'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ), + 'description' => sprintf( + /* translators: %s: site timezone string, e.g. "Asia/Singapore". */ + esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ), + esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' ) + ), ], [ 'id' => 'prautoblogger_monthly_budget_usd', @@ -260,6 +264,14 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '1', 'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), ], + [ + 'id' => 'prautoblogger_cf_image_via_gateway', + 'label' => __( 'Route Cloudflare Images via AI Gateway', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Route Cloudflare Workers AI image calls through the Cloudflare AI Gateway (requires the AI Gateway URL configured under API Keys). Adds response caching, cost logging, and rate-limiting. Turn off to hit the direct Workers AI API — only needed if the gateway route regresses.', 'prautoblogger' ), + ], ]; } } diff --git a/includes/class-activator.php b/includes/class-activator.php index 0177026..476bbd4 100755 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -34,116 +34,9 @@ class PRAutoBlogger_Activator { update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false ); } - /** - * Create all custom database tables using dbDelta. - * - * Side effects: creates/updates four database tables. - * - * @return void - */ + /** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */ private static function create_tables(): void { - global $wpdb; - - $charset_collate = $wpdb->get_charset_collate(); - $prefix = $wpdb->prefix . 'prautoblogger_'; - - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - - // Source data — raw posts/comments from social platforms. - $sql_source_data = "CREATE TABLE {$prefix}source_data ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - source_type VARCHAR(50) NOT NULL, - source_id VARCHAR(255) NOT NULL, - subreddit VARCHAR(255) DEFAULT NULL, - title TEXT DEFAULT NULL, - content LONGTEXT DEFAULT NULL, - author VARCHAR(255) DEFAULT NULL, - score INT DEFAULT 0, - comment_count INT DEFAULT 0, - permalink VARCHAR(500) DEFAULT NULL, - collected_at DATETIME NOT NULL, - metadata_json LONGTEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY source_unique (source_type, source_id), - KEY source_collected (source_type, collected_at) - ) {$charset_collate};"; - - // Analysis results — detected patterns from source data. - $sql_analysis = "CREATE TABLE {$prefix}analysis_results ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - analysis_type VARCHAR(50) NOT NULL, - topic VARCHAR(500) NOT NULL, - summary TEXT DEFAULT NULL, - frequency INT DEFAULT 1, - relevance_score FLOAT DEFAULT 0, - source_ids_json LONGTEXT DEFAULT NULL, - analyzed_at DATETIME NOT NULL, - metadata_json LONGTEXT DEFAULT NULL, - PRIMARY KEY (id), - KEY type_relevance (analysis_type, relevance_score), - KEY analyzed_at (analyzed_at) - ) {$charset_collate};"; - - // Generation log — every API call with cost tracking. - // run_id groups all log entries from a single pipeline execution so - // link_generation_logs() can accurately attribute costs to a post. - $sql_generation_log = "CREATE TABLE {$prefix}generation_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - post_id BIGINT UNSIGNED DEFAULT NULL, - run_id VARCHAR(36) DEFAULT NULL, - stage VARCHAR(50) NOT NULL, - provider VARCHAR(50) NOT NULL, - model VARCHAR(100) NOT NULL, - prompt_tokens INT DEFAULT 0, - completion_tokens INT DEFAULT 0, - estimated_cost DECIMAL(10,6) DEFAULT 0, - request_json LONGTEXT DEFAULT NULL, - response_status VARCHAR(20) NOT NULL DEFAULT 'success', - error_message TEXT DEFAULT NULL, - created_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY post_id (post_id), - KEY run_id (run_id), - KEY created_at (created_at), - KEY stage (stage) - ) {$charset_collate};"; - - // Content scores — post performance metrics for self-improvement. - $sql_content_scores = "CREATE TABLE {$prefix}content_scores ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - post_id BIGINT UNSIGNED NOT NULL, - pageviews INT DEFAULT 0, - avg_time_on_page FLOAT DEFAULT 0, - bounce_rate FLOAT DEFAULT 0, - comment_count INT DEFAULT 0, - composite_score FLOAT DEFAULT 0, - score_factors_json LONGTEXT DEFAULT NULL, - measured_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY post_id (post_id), - KEY measured_at (measured_at), - KEY composite_score (composite_score) - ) {$charset_collate};"; - - // Event log — structured application log entries for the admin log viewer. - $sql_event_log = "CREATE TABLE {$prefix}event_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - level VARCHAR(10) NOT NULL DEFAULT 'info', - context VARCHAR(100) DEFAULT NULL, - message TEXT NOT NULL, - meta_json LONGTEXT DEFAULT NULL, - created_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY level (level), - KEY created_at (created_at), - KEY level_created (level, created_at) - ) {$charset_collate};"; - - dbDelta( $sql_source_data ); - dbDelta( $sql_analysis ); - dbDelta( $sql_generation_log ); - dbDelta( $sql_content_scores ); - dbDelta( $sql_event_log ); + PRAutoBlogger_Schema_Installer::install(); } /** @@ -175,6 +68,8 @@ class PRAutoBlogger_Activator { 'prautoblogger_table_borders' => '1', 'prautoblogger_schedule_time' => '03:00', 'prautoblogger_log_level' => 'info', + 'prautoblogger_image_nsfw_retry' => '1', + 'prautoblogger_cf_image_via_gateway' => '1', ]; foreach ( $defaults as $key => $value ) { @@ -196,14 +91,8 @@ class PRAutoBlogger_Activator { */ private static function schedule_cron(): void { if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) { - // Schedule for the configured time tomorrow. - $time_str = get_option( 'prautoblogger_schedule_time', '03:00' ); - $parts = explode( ':', $time_str ); - $hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3; - $minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0; - $timestamp = strtotime( "tomorrow {$hour}:{$minute}" ); - - if ( false !== $timestamp ) { + $timestamp = self::next_daily_generation_timestamp(); + if ( $timestamp > 0 ) { wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' ); } } @@ -239,6 +128,75 @@ class PRAutoBlogger_Activator { } } + /** + * Compute the next daily-generation timestamp in the site's configured + * timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")` + * evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`), + * firing the cron N hours off from user intent where N is the site's + * UTC offset. + * + * @return int Unix timestamp, or 0 on any failure. + */ + public static function next_daily_generation_timestamp(): int { + $time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' ); + $parts = explode( ':', $time_str ); + $hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3; + $minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0; + + try { + $site_tz = function_exists( 'wp_timezone' ) + ? wp_timezone() + : new \DateTimeZone( 'UTC' ); + $local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) ) + ->setTime( $hour, $minute, 0 ); + return (int) $local->getTimestamp(); + } catch ( \Exception $e ) { + // Defensive: never leave the site without any schedule because + // of a bad tz string. Fall back to the pre-fix behaviour. + $timestamp = strtotime( "tomorrow {$hour}:{$minute}" ); + return false === $timestamp ? 0 : (int) $timestamp; + } + } + + /** + * v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation` + * in the site's timezone. + * + * Existing installs had their daily cron scheduled with the v<0.8.2 + * UTC-interpretation bug. This clears the stale event and reschedules it + * using the timezone-aware helper, exactly once per site (gated by + * `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the + * before/after next-run times so the outcome is auditable. + * + * @return void + */ + public static function reschedule_daily_in_site_timezone_v082(): void { + if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) { + return; + } + + $old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' ); + wp_clear_scheduled_hook( 'prautoblogger_daily_generation' ); + + $new_timestamp = self::next_daily_generation_timestamp(); + if ( $new_timestamp > 0 ) { + wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' ); + } + + if ( class_exists( 'PRAutoBlogger_Logger', false ) ) { + PRAutoBlogger_Logger::instance()->info( + sprintf( + 'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.', + false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled', + $new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled' + ), + 'activator' + ); + } + + update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' ); + } + /** * One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id` * post_meta for existing posts from their gen-log rows. diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 7441c58..d1e5878 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -192,6 +192,13 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_image_provider_v080', '1' ); } + // One-time migration (v0.8.2): reschedule the daily-generation cron in + // the site's configured timezone. Pre-v0.8.2 activator interpreted the + // admin "Generation Time" input as UTC; after v0.8.2 it honours the + // site timezone. Clears the stale UTC-scheduled event and re-queues + // using the timezone-aware helper. See class-activator.php. + PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082(); + // One-time migration (v3): switch to single-panel newspaper comic style. // Replaces both the old infomercial pastiche and the short-lived premium // photography style. Force-update unless the user has a truly custom value. diff --git a/includes/class-schema-installer.php b/includes/class-schema-installer.php new file mode 100644 index 0000000..b9df8dc --- /dev/null +++ b/includes/class-schema-installer.php @@ -0,0 +1,136 @@ +get_charset_collate(); + $prefix = $wpdb->prefix . 'prautoblogger_'; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + // Source data — raw posts/comments from social platforms. + $sql_source_data = "CREATE TABLE {$prefix}source_data ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source_type VARCHAR(50) NOT NULL, + source_id VARCHAR(255) NOT NULL, + subreddit VARCHAR(255) DEFAULT NULL, + title TEXT DEFAULT NULL, + content LONGTEXT DEFAULT NULL, + author VARCHAR(255) DEFAULT NULL, + score INT DEFAULT 0, + comment_count INT DEFAULT 0, + permalink VARCHAR(500) DEFAULT NULL, + collected_at DATETIME NOT NULL, + metadata_json LONGTEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY source_unique (source_type, source_id), + KEY source_collected (source_type, collected_at) + ) {$charset_collate};"; + + // Analysis results — detected patterns from source data. + $sql_analysis = "CREATE TABLE {$prefix}analysis_results ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + analysis_type VARCHAR(50) NOT NULL, + topic VARCHAR(500) NOT NULL, + summary TEXT DEFAULT NULL, + frequency INT DEFAULT 1, + relevance_score FLOAT DEFAULT 0, + source_ids_json LONGTEXT DEFAULT NULL, + analyzed_at DATETIME NOT NULL, + metadata_json LONGTEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY type_relevance (analysis_type, relevance_score), + KEY analyzed_at (analyzed_at) + ) {$charset_collate};"; + + // Generation log — every API call with cost tracking. `run_id` + // groups all entries from a single pipeline execution so + // `link_generation_logs()` can accurately attribute costs to a post. + $sql_generation_log = "CREATE TABLE {$prefix}generation_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED DEFAULT NULL, + run_id VARCHAR(36) DEFAULT NULL, + stage VARCHAR(50) NOT NULL, + provider VARCHAR(50) NOT NULL, + model VARCHAR(100) NOT NULL, + prompt_tokens INT DEFAULT 0, + completion_tokens INT DEFAULT 0, + estimated_cost DECIMAL(10,6) DEFAULT 0, + request_json LONGTEXT DEFAULT NULL, + response_status VARCHAR(20) NOT NULL DEFAULT 'success', + error_message TEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY run_id (run_id), + KEY created_at (created_at), + KEY stage (stage) + ) {$charset_collate};"; + + // Content scores — post performance metrics for self-improvement. + $sql_content_scores = "CREATE TABLE {$prefix}content_scores ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + pageviews INT DEFAULT 0, + avg_time_on_page FLOAT DEFAULT 0, + bounce_rate FLOAT DEFAULT 0, + comment_count INT DEFAULT 0, + composite_score FLOAT DEFAULT 0, + score_factors_json LONGTEXT DEFAULT NULL, + measured_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY measured_at (measured_at), + KEY composite_score (composite_score) + ) {$charset_collate};"; + + // Event log — structured application log entries for the admin log viewer. + $sql_event_log = "CREATE TABLE {$prefix}event_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL DEFAULT 'info', + context VARCHAR(100) DEFAULT NULL, + message TEXT NOT NULL, + meta_json LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY level (level), + KEY created_at (created_at), + KEY level_created (level, created_at) + ) {$charset_collate};"; + + dbDelta( $sql_source_data ); + dbDelta( $sql_analysis ); + dbDelta( $sql_generation_log ); + dbDelta( $sql_content_scores ); + dbDelta( $sql_event_log ); + } +} diff --git a/includes/providers/class-cloudflare-image-support.php b/includes/providers/class-cloudflare-image-support.php index 002f88a..bd29b31 100644 --- a/includes/providers/class-cloudflare-image-support.php +++ b/includes/providers/class-cloudflare-image-support.php @@ -55,11 +55,26 @@ class PRAutoBlogger_Cloudflare_Image_Support { /** * Build the Workers AI run URL for a given account + model. * + * By default routes through the Cloudflare AI Gateway we already use for + * OpenRouter — the gateway route to Workers AI was observed 403ing in + * April 2026 (see ARCHITECTURE.md #16), but manual probe on 2026-04-21 + * shows it's now returning 200. Gateway routing gives us response caching, + * cost logging, rate limiting, and provider fallback for free. + * + * Direct-API fallback kicks in automatically when either the + * `prautoblogger_ai_gateway_base_url` option is empty (no gateway + * configured at all) or the new `prautoblogger_cf_image_via_gateway` + * toggle is off (explicit opt-out for if the gateway route regresses). + * * @param string $account_id Cloudflare account UUID. - * @param string $model Fully-qualified Workers AI model id. + * @param string $model Fully-qualified Workers AI model id (e.g. @cf/black-forest-labs/flux-1-schnell). * @return string Fully-formed HTTPS URL. */ public function build_endpoint_url( string $account_id, string $model ): string { + $gateway_url = $this->build_gateway_workers_ai_url( $account_id, $model ); + if ( '' !== $gateway_url ) { + return $gateway_url; + } return sprintf( 'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s', rawurlencode( $account_id ), @@ -67,6 +82,48 @@ class PRAutoBlogger_Cloudflare_Image_Support { ); } + /** + * Derive a Workers AI URL rooted at the same AI Gateway we already use + * for OpenRouter. Returns empty string when the gateway isn't configured + * or the admin toggle opts out — the caller falls back to direct API. + * + * The existing `prautoblogger_ai_gateway_base_url` option points at + * `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openrouter` + * for the LLM path; we strip the `/openrouter` suffix and append + * `/workers-ai/{model}` for the image path. Defensive: only emits a URL + * that pattern-matches, so a bad option value quietly falls back. + * + * @param string $account_id Cloudflare account UUID (kept for symmetry; not used in URL derivation). + * @param string $model Fully-qualified Workers AI model id. + * @return string Gateway URL, or empty string if unavailable. + */ + private function build_gateway_workers_ai_url( string $account_id, string $model ): string { + unset( $account_id ); + if ( '1' !== (string) get_option( 'prautoblogger_cf_image_via_gateway', '1' ) ) { + return ''; + } + $gateway_base = rtrim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ), '/' ); + if ( '' === $gateway_base ) { + return ''; + } + // Must be an https CF AI Gateway URL. + if ( 0 !== stripos( $gateway_base, 'https://gateway.ai.cloudflare.com/' ) ) { + return ''; + } + // The configured URL ends with `/openrouter` (LLM route). The gateway + // root — shared with the Workers AI route — is everything before the + // last `/`. If no `/` is present (defensive), we drop back to direct API. + $last_slash = strrpos( $gateway_base, '/' ); + if ( false === $last_slash ) { + return ''; + } + $gateway_root = substr( $gateway_base, 0, $last_slash ); + if ( '' === $gateway_root ) { + return ''; + } + return sprintf( '%s/workers-ai/%s', $gateway_root, ltrim( $model, '/' ) ); + } + /** * Emit a WARN line for a retryable failure. * diff --git a/prautoblogger.php b/prautoblogger.php index 7293201..fda4d44 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.8.1 + * Version: 0.8.2 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.8.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.8.2' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/ActivatorMigrationTest.php b/tests/unit/Core/ActivatorMigrationTest.php new file mode 100644 index 0000000..cf64dd8 --- /dev/null +++ b/tests/unit/Core/ActivatorMigrationTest.php @@ -0,0 +1,133 @@ + + */ + private array $cron_state = []; + + /** + * Track the v0.8.2 migration flag across get_option/update_option calls. + * + * @var array + */ + private array $options = []; + + protected function setUp(): void { + parent::setUp(); + + $this->cron_state = [ + 'prautoblogger_daily_generation' => [ + 'scheduled_at' => null, + 'recurrence' => null, + 'cleared' => false, + ], + ]; + + // Start with a pre-existing cron simulating the v<0.8.2 UTC-interpreted + // schedule, so the migration has something to clear. + $this->cron_state['prautoblogger_daily_generation']['scheduled_at'] = strtotime( 'tomorrow 03:00 UTC' ); + $this->cron_state['prautoblogger_daily_generation']['recurrence'] = 'daily'; + + // Baseline options. + $this->options = [ + 'prautoblogger_schedule_time' => '06:00', + 'prautoblogger_log_level' => 'info', + ]; + + Functions\when( 'get_option' )->alias( + fn( $key, $default = false ) => $this->options[ $key ] ?? $default + ); + Functions\when( 'update_option' )->alias( + function ( $key, $value ) { + $this->options[ $key ] = $value; + return true; + } + ); + + Functions\when( 'wp_next_scheduled' )->alias( + fn( $hook ) => $this->cron_state[ $hook ]['scheduled_at'] ?? false + ); + Functions\when( 'wp_clear_scheduled_hook' )->alias( + function ( $hook ) { + if ( isset( $this->cron_state[ $hook ] ) ) { + $this->cron_state[ $hook ]['scheduled_at'] = null; + $this->cron_state[ $hook ]['cleared'] = true; + } + } + ); + Functions\when( 'wp_schedule_event' )->alias( + function ( $timestamp, $recurrence, $hook ) { + $this->cron_state[ $hook ]['scheduled_at'] = (int) $timestamp; + $this->cron_state[ $hook ]['recurrence'] = (string) $recurrence; + return true; + } + ); + + Functions\when( 'wp_timezone' )->alias( + static fn() => new \DateTimeZone( 'Asia/Singapore' ) + ); + } + + /** + * Migration clears the UTC-scheduled event and reschedules in SGT. + * Flag is set so the next pass is a no-op. + */ + public function test_db_version_migration_reschedules_existing_cron(): void { + $before_timestamp = $this->cron_state['prautoblogger_daily_generation']['scheduled_at']; + $this->assertNotNull( $before_timestamp, 'Precondition: stale cron was pre-seeded.' ); + + \PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082(); + + // Hook was cleared and then re-scheduled in one pass. + $this->assertTrue( $this->cron_state['prautoblogger_daily_generation']['cleared'] ); + $after = $this->cron_state['prautoblogger_daily_generation']['scheduled_at']; + $this->assertNotNull( $after ); + $this->assertNotSame( $before_timestamp, $after, 'Migration should produce a different timestamp.' ); + + // Re-scheduled timestamp maps to 06:00 SGT (22:00 UTC prev day). + $utc = ( new \DateTimeImmutable( '@' . $after ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $this->assertSame( '22:00', $utc->format( 'H:i' ) ); + + // Flag was set. + $this->assertSame( '1', $this->options['prautoblogger_migrated_schedule_tz_v082'] ?? null ); + } + + /** + * Running the migration twice is idempotent — the second pass must + * return immediately without clearing or rescheduling. + */ + public function test_db_version_migration_idempotent(): void { + \PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082(); + + // Reset the cleared marker so we can detect a second clear. + $this->cron_state['prautoblogger_daily_generation']['cleared'] = false; + $timestamp_after_first = $this->cron_state['prautoblogger_daily_generation']['scheduled_at']; + + \PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082(); + + // No second clear, no re-schedule. + $this->assertFalse( $this->cron_state['prautoblogger_daily_generation']['cleared'] ); + $this->assertSame( + $timestamp_after_first, + $this->cron_state['prautoblogger_daily_generation']['scheduled_at'] + ); + } +} diff --git a/tests/unit/Core/ActivatorScheduleTest.php b/tests/unit/Core/ActivatorScheduleTest.php new file mode 100644 index 0000000..fefd737 --- /dev/null +++ b/tests/unit/Core/ActivatorScheduleTest.php @@ -0,0 +1,91 @@ +stub_get_option( [ + 'prautoblogger_schedule_time' => $time_str, + ] ); + Functions\when( 'wp_timezone' )->alias( + static fn() => new \DateTimeZone( $tz_id ) + ); + } + + /** + * On an Asia/Singapore site (UTC+8), `schedule_time = 06:00` must + * resolve to 22:00 UTC the previous day — the prod bug from thread + * scheduled-gen-timezone seq 1. + */ + public function test_schedule_cron_uses_site_timezone(): void { + $this->with_site_tz_and_time( 'Asia/Singapore', '06:00' ); + + $timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp(); + + $this->assertGreaterThan( 0, $timestamp ); + $utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $this->assertSame( '22:00', $utc->format( 'H:i' ), 'Expected 06:00 SGT == 22:00 UTC previous day' ); + + // And it really is "tomorrow" in Singapore — date part should not be today SGT. + $sgt = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'Asia/Singapore' ) ); + $tomorrow_sgt = ( new \DateTimeImmutable( 'tomorrow', new \DateTimeZone( 'Asia/Singapore' ) ) )->format( 'Y-m-d' ); + $this->assertSame( $tomorrow_sgt, $sgt->format( 'Y-m-d' ) ); + } + + /** + * UTC site, `06:00` input → 06:00 UTC. Behaviour unchanged for + * the always-UTC case; regression guard. + */ + public function test_schedule_cron_handles_utc_site(): void { + $this->with_site_tz_and_time( 'UTC', '06:00' ); + + $timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp(); + + $utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $this->assertSame( '06:00', $utc->format( 'H:i' ) ); + } + + /** + * Nepal runs UTC+5:45. 06:00 local → 00:15 UTC. Guards against any + * integer-hour-offset assumptions in the conversion logic. + */ + public function test_schedule_cron_handles_fractional_offset(): void { + $this->with_site_tz_and_time( 'Asia/Kathmandu', '06:00' ); + + $timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp(); + + $utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $this->assertSame( '00:15', $utc->format( 'H:i' ) ); + } + + /** + * Malformed `schedule_time` (e.g. blank string saved accidentally) + * must not throw — `absint()` clamps to 0:0 and the helper returns + * a valid timestamp. Regression guard against the "bricked schedule + * on bad input" failure mode. + */ + public function test_schedule_cron_survives_malformed_time_input(): void { + $this->with_site_tz_and_time( 'UTC', '' ); + + $timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp(); + + $this->assertGreaterThan( 0, $timestamp ); + } +} diff --git a/tests/unit/Providers/CloudflareImageSupportTest.php b/tests/unit/Providers/CloudflareImageSupportTest.php new file mode 100644 index 0000000..da160e2 --- /dev/null +++ b/tests/unit/Providers/CloudflareImageSupportTest.php @@ -0,0 +1,115 @@ +stub_get_option( [ + 'prautoblogger_ai_gateway_base_url' => '', + 'prautoblogger_cf_image_via_gateway' => '1', + ] ); + + $support = new \PRAutoBlogger_Cloudflare_Image_Support(); + $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); + + $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); + $this->assertStringContainsString( self::ACCOUNT_ID, $url ); + $this->assertStringEndsWith( self::MODEL, $url ); + } + + /** + * With the AI Gateway URL configured and the toggle on, image calls + * route through `gateway.ai.cloudflare.com/.../workers-ai/{model}` at + * the same gateway root as the OpenRouter LLM path. + */ + public function test_returns_gateway_url_when_configured_and_toggle_on(): void { + $this->stub_get_option( [ + 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', + 'prautoblogger_cf_image_via_gateway' => '1', + ] ); + + $support = new \PRAutoBlogger_Cloudflare_Image_Support(); + $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); + + $this->assertSame( + 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/workers-ai/' . self::MODEL, + $url + ); + } + + /** + * The toggle overrides the gateway URL: operators can fall back to the + * direct API if the gateway regresses, without clearing the OpenRouter + * gateway setting that the LLM provider depends on. + */ + public function test_toggle_off_forces_direct_url_even_with_gateway_configured(): void { + $this->stub_get_option( [ + 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', + 'prautoblogger_cf_image_via_gateway' => '0', + ] ); + + $support = new \PRAutoBlogger_Cloudflare_Image_Support(); + $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); + + $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); + } + + /** + * A malformed gateway URL (e.g. http, or a totally different host) is + * rejected and the direct-API path is used instead. Defensive — a bad + * option value shouldn't break image generation. + */ + public function test_falls_back_to_direct_when_gateway_url_is_malformed(): void { + $this->stub_get_option( [ + 'prautoblogger_ai_gateway_base_url' => 'http://evil.example.com/openrouter', + 'prautoblogger_cf_image_via_gateway' => '1', + ] ); + + $support = new \PRAutoBlogger_Cloudflare_Image_Support(); + $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); + + $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); + } + + /** + * The NSFW detector must match on the documented error code without + * tripping on other 4xx bodies. (Covered at the provider level too, + * but worth pinning at the support class so a tolerance regression + * fails early.) + */ + public function test_is_nsfw_error_matches_code_3030_only(): void { + $support = new \PRAutoBlogger_Cloudflare_Image_Support(); + + $this->assertTrue( + $support->is_nsfw_error( '{"errors":[{"code":3030,"message":"NSFW"}]}' ) + ); + $this->assertFalse( + $support->is_nsfw_error( '{"errors":[{"code":7003,"message":"bad token"}]}' ) + ); + $this->assertFalse( + $support->is_nsfw_error( 'not json at all' ) + ); + $this->assertFalse( + $support->is_nsfw_error( '{}' ) + ); + } +}