'slug' ) ); $language = in_array( 'zh', $languages, true ) ? 'zh' : ''; } /** * Language of the posts sent to the WeChat Official Account. * * @param string $language Polylang language slug, or empty for any. */ return sanitize_key( (string) apply_filters( 'ai_chat_bedrock_wechat_drafts_language', $language ) ); } /** * The newest featured posts not sent before. * * @param array|null $options Settings. * @param int $limit Posts wanted. * @param bool $recent Only posts of the last RECENT_DAYS days. * @return int[] Post IDs, newest first. */ public static function candidates( $options = null, $limit = 0, $recent = true ) { $options = self::options( $options ); $limit = $limit > 0 ? (int) $limit : self::count( $options ); $args = array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 40, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, ); if ( $recent ) { $args['date_query'] = array( array( 'after' => self::RECENT_DAYS . ' days ago' ) ); } if ( self::category( $options ) ) { $args['cat'] = self::category( $options ); } if ( '' !== self::language() ) { $args['lang'] = self::language(); } $found = array(); foreach ( get_posts( $args ) as $post ) { if ( count( $found ) >= $limit ) { break; } // Only a post that makes a full article; see shortfall(). if ( ! self::sent( $post->ID ) && '' === self::shortfall( $post, $options ) ) { $found[] = (int) $post->ID; } } return $found; } /** * Delete a post's older drafts in WeChat and mark them removed in its record. * * Only drafts, never a published article, which is no longer a draft; and only a draft no * other post was sent in. * * @param int $post_id Post ID. * @param string $current The draft the post is in now. * @param array $options Settings. * @return int Drafts deleted. */ public static function retire( $post_id, $current, $options ) { $deleted = 0; foreach ( AI_Chat_Bedrock_Distribution::entries( $post_id ) as $entry ) { if ( ! isset( $entry['platform'], $entry['status'], $entry['item_id'] ) || 'wechat' !== $entry['platform'] || 'planned' !== $entry['status'] || $current === $entry['item_id'] || ! isset( $entry['source'] ) || 'wechat' !== $entry['source'] ) { continue; } if ( self::shared( $entry['item_id'], $post_id ) ) { continue; } $gone = AI_Chat_Bedrock_WeChat_API::call( 'draft/delete', wp_json_encode( array( 'media_id' => $entry['item_id'] ) ), self::account( $options ), 10 ); if ( is_wp_error( $gone ) && ! in_array( $gone->get_error_code(), self::DRAFT_GONE, true ) ) { continue; } AI_Chat_Bedrock_Distribution::record( $post_id, array( 'platform' => 'wechat', 'item_id' => $entry['item_id'], 'status' => 'removed', 'note' => __( 'An older draft of this post, deleted when it was sent again.', 'ai-chat-for-amazon-bedrock' ), ), 'wechat' ); ++$deleted; } return $deleted; } /** * Whether another post was sent in a draft. * * @param string $media_id Draft. * @param int $post_id The post asking. * @return bool */ private static function shared( $media_id, $post_id ) { $others = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => 2, 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- only when a post is sent again. array( 'key' => AI_Chat_Bedrock_Distribution::META, 'value' => $media_id, 'compare' => 'LIKE', ), ), ) ); return (bool) array_diff( array_map( 'intval', (array) $others ), array( (int) $post_id ) ); } /** * The draft a post was last sent to, while it is still a draft. * * @param int $post_id Post ID. * @return array|null media_id and the article's index in it. */ public static function draft_of( $post_id ) { foreach ( AI_Chat_Bedrock_Distribution::entries( $post_id ) as $entry ) { if ( isset( $entry['platform'], $entry['status'], $entry['item_id'] ) && 'wechat' === $entry['platform'] ) { if ( 'planned' !== $entry['status'] ) { return null; } // Only a draft the plugin sent; any other "draft" entry is not trusted. if ( ! isset( $entry['source'] ) || 'wechat' !== $entry['source'] ) { continue; } return array( 'media_id' => (string) $entry['item_id'], 'index' => isset( $entry['version'] ) && preg_match( '/^idx:(\d)$/', (string) $entry['version'], $found ) ? (int) $found[1] : 0, 'updated_at' => isset( $entry['updated_at'] ) ? (int) $entry['updated_at'] : 0, ); } } return null; } /** * Whether a post was sent to the account before. * * @param int $post_id Post ID. * @return bool */ public static function sent( $post_id ) { foreach ( AI_Chat_Bedrock_Distribution::entries( $post_id ) as $entry ) { if ( isset( $entry['platform'] ) && 'wechat' === $entry['platform'] ) { return true; } } // A draft whose answer was lost may hold the post in WeChat: the post counts as sent until // the owner has reconciled that day. foreach ( self::daily_drafts() as $account_id => $days ) { foreach ( $days as $date => $entry ) { if ( self::$reservation && self::$reservation['account'] === $account_id && self::$reservation['date'] === $date ) { continue; } if ( isset( $entry['status'] ) && in_array( $entry['status'], array( 'reserved', 'unknown' ), true ) && in_array( (int) $post_id, array_map( 'intval', isset( $entry['post_ids'] ) ? (array) $entry['post_ids'] : array() ), true ) ) { return true; } } } return false; } /** * Today's date for the daily limit, in Asia/Singapore. * * @param int|null $time Unix time; now when null. * @return string Y-m-d. */ public static function today( $time = null ) { $date = new DateTime( '@' . ( null === $time ? time() : (int) $time ) ); $date->setTimezone( new DateTimeZone( self::DAILY_ZONE ) ); return $date->format( 'Y-m-d' ); } /** * The daily ledger: for each Official Account and day, the day's one draft and the day's pick. * * @return array */ public static function ledger() { $ledger = get_option( self::DAILY_OPTION, array() ); return is_array( $ledger ) ? $ledger : array(); } /** * Store the ledger and read it back: true only when storage now holds exactly this ledger. * update_option() alone says false both for an unchanged value and for a failed write. * * @param array $ledger The whole ledger. * @return bool */ private static function save_ledger( $ledger ) { update_option( self::DAILY_OPTION, $ledger, false ); if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( self::DAILY_OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); } return get_option( self::DAILY_OPTION, null ) === $ledger; } /** * The ledger lock, waiting a bounded time for another request to let go. * * @param int $wait_ms How long to keep trying; 0 tries once. * @return string The lock to release, or '' when it stayed taken. */ private static function lock_ledger( $wait_ms = 0 ) { $until = microtime( true ) + $wait_ms / 1000; do { $lock = AI_Chat_Bedrock_Security::acquire_lock( self::DAILY_LOCK, self::DAILY_LOCK_TTL ); if ( '' !== $lock ) { return $lock; } if ( microtime( true ) >= $until ) { return ''; } usleep( 50000 ); } while ( true ); } /** * The ledger entry a reservation stands for, while it is still that attempt's and unsettled. * * @param array $ledger The ledger, read under the lock. * @param array $reservation From reserve(). * @return array|null The entry, or null when another operation changed or settled the day. */ private static function reserved_entry( $ledger, $reservation ) { $entry = isset( $ledger['drafts'][ $reservation['account'] ][ $reservation['date'] ] ) ? $ledger['drafts'][ $reservation['account'] ][ $reservation['date'] ] : null; if ( ! is_array( $entry ) || 'reserved' !== ( isset( $entry['status'] ) ? $entry['status'] : '' ) || ! isset( $entry['attempt'], $reservation['attempt'] ) || ! hash_equals( (string) $entry['attempt'], (string) $reservation['attempt'] ) ) { return null; } return $entry; } /** * The drafts of the ledger, by account and day. * * @return array */ public static function daily_drafts() { $ledger = self::ledger(); return isset( $ledger['drafts'] ) && is_array( $ledger['drafts'] ) ? $ledger['drafts'] : array(); } /** * The post picked for an account and day, or null. * * @param string $account_id AppID. * @param string $date Y-m-d, Asia/Singapore. * @return array|null */ public static function pick_for( $account_id, $date ) { $ledger = self::ledger(); return isset( $ledger['picks'][ $account_id ][ $date ] ) && is_array( $ledger['picks'][ $account_id ][ $date ] ) ? $ledger['picks'][ $account_id ][ $date ] : null; } /** * Why no new draft may be made for the account today, or an empty string. * * @param string $account_id AppID. * @param string $date Today, Y-m-d. * @param array|null $ledger The ledger, when already read. * @return string wx_daily_done, wx_daily_unknown, or ''. */ public static function daily_block( $account_id, $date, $ledger = null ) { $ledger = null === $ledger ? self::ledger() : $ledger; $days = isset( $ledger['drafts'][ $account_id ] ) && is_array( $ledger['drafts'][ $account_id ] ) ? $ledger['drafts'][ $account_id ] : array(); foreach ( $days as $day => $entry ) { // An earlier day whose draft has no known result holds every later day, until the owner // has looked at the draft box; a day's end does not settle it. if ( $day !== $date && isset( $entry['status'] ) && in_array( $entry['status'], array( 'reserved', 'unknown' ), true ) ) { return 'wx_daily_unknown'; } } if ( isset( $days[ $date ]['status'] ) && in_array( $days[ $date ]['status'], self::DAILY_TAKEN, true ) ) { return 'wx_daily_done'; } return ''; } /** * Take the day for one new draft, under the one lock every way of making a draft shares. * * @param array $options Settings. * @param int[] $post_ids Posts the draft is for. * @param string $source manual, agent or schedule. * @return array|WP_Error The reservation, or why the day is not available. */ private static function reserve( $options, $post_ids, $source ) { $account_id = AI_Chat_Bedrock_WeChat::app_id( $options ); $date = self::today(); $attempt = bin2hex( random_bytes( 8 ) ); $lock = self::lock_ledger(); if ( '' === $lock ) { return new WP_Error( 'wx_daily_busy', 'Another draft is being made.' ); } try { $ledger = self::ledger(); $block = self::daily_block( $account_id, $date, $ledger ); if ( '' !== $block ) { return new WP_Error( $block, 'The day is taken.' ); } $ledger['drafts'][ $account_id ][ $date ] = array( 'status' => 'reserved', 'attempt' => $attempt, 'post_ids' => array_values( array_map( 'intval', (array) $post_ids ) ), 'source' => $source, 'at' => time(), ); // Nothing is uploaded or sent unless the reservation is durably there to block a replay. if ( ! self::save_ledger( $ledger ) ) { return new WP_Error( 'wx_daily_store', 'The day could not be reserved.' ); } } finally { AI_Chat_Bedrock_Security::release_lock( self::DAILY_LOCK, $lock ); } self::$reservation = array( 'account' => $account_id, 'date' => $date, 'attempt' => $attempt, ); return self::$reservation; } /** * Record how the day's draft ended: accepted, unknown, refused, or nothing (no draft was sent). * * Only this attempt's own, still reserved entry is written, under the ledger lock and from a * fresh read, so a concurrent pick or an owner's reconciliation is never overwritten. * * @param array|null $reservation From reserve(); nothing happens for null. * @param string $status New status. * @param array $extra media_id, code, post_ids. * @return bool Whether the answer is now durably recorded; true for a null reservation. */ private static function settle( $reservation, $status, $extra = array() ) { self::$reservation = null; if ( ! is_array( $reservation ) ) { return true; } $lock = self::lock_ledger( self::DAILY_LOCK_WAIT_MS ); if ( '' === $lock ) { return false; } try { $ledger = self::ledger(); $entry = self::reserved_entry( $ledger, $reservation ); if ( null === $entry ) { return false; } $ledger['drafts'][ $reservation['account'] ][ $reservation['date'] ] = array_merge( $entry, $extra, array( 'status' => $status, 'answered_at' => time(), ) ); return self::save_ledger( $ledger ); } finally { AI_Chat_Bedrock_Security::release_lock( self::DAILY_LOCK, $lock ); } } /** * Freeze what is about to be sent into the day's reservation, so that the owner can later tell * this draft from any other in the draft box: each article's title, address and text, the * reviews it was converted from, and for the scheduled run the pick it fulfils. * * @param array $reservation From reserve(). * @param array $articles The outbound articles. * @param array $reviewed Post ID => review digest, for automatic sources. * @param array|null $pick The day's pick, for the scheduled run. * @return bool Whether the reservation is still this attempt's and now carries the payload. */ private static function bind( $reservation, $articles, $reviewed, $pick ) { if ( ! is_array( $reservation ) ) { return true; } $lock = self::lock_ledger( self::DAILY_LOCK_WAIT_MS ); if ( '' === $lock ) { return false; } try { $ledger = self::ledger(); $entry = self::reserved_entry( $ledger, $reservation ); if ( null === $entry ) { return false; } $entry['payload'] = array_map( array( __CLASS__, 'article_identity' ), array_values( $articles ) ); $entry['reviews'] = array_map( 'strval', $reviewed ); if ( is_array( $pick ) ) { $entry['pick'] = array( 'post_id' => (int) $pick['post_id'], 'review_digest' => (string) $pick['review_digest'], ); } $entry['bound_at'] = time(); $ledger['drafts'][ $reservation['account'] ][ $reservation['date'] ] = $entry; return self::save_ledger( $ledger ); } finally { AI_Chat_Bedrock_Security::release_lock( self::DAILY_LOCK, $lock ); } } /** * What identifies an article across WeChat's own rewriting of its HTML: title, source address, * and the hash of its text with markup, entities and whitespace taken out. * * @param array $article An outbound or native article (title, content_source_url, content). * @return array title, content_source_url, text_sha256. */ public static function article_identity( $article ) { return array( 'title' => isset( $article['title'] ) ? (string) $article['title'] : '', 'content_source_url' => isset( $article['content_source_url'] ) ? (string) $article['content_source_url'] : '', 'text_sha256' => hash( 'sha256', self::article_text( isset( $article['content'] ) ? (string) $article['content'] : '' ) ), ); } /** * The readable text of article HTML, with tags, entities and all whitespace removed. * * @param string $html Article HTML. * @return string */ public static function article_text( $html ) { $text = preg_replace( '/<(script|style)\b[^>]*>.*?<\/\1>/is', '', $html ); $text = html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = str_replace( "\xC2\xA0", ' ', $text ); return (string) preg_replace( '/\s+/u', '', $text ); } /** * The scheduled run writes only the day's pick, as picked: the same post, with the review it * was picked at still the current one. * * @param array $options Settings. * @param WP_Post $post The post about to be converted or sent. * @param string $digest Its current review digest. * @return array|null The pick, or null when the pick is missing, another post's or stale. */ private static function pick_binding( $options, $post, $digest ) { $pick = self::pick_for( AI_Chat_Bedrock_WeChat::app_id( $options ), self::today() ); if ( ! is_array( $pick ) || (int) $pick['post_id'] !== (int) $post->ID || ! isset( $pick['review_digest'] ) || ! hash_equals( (string) $pick['review_digest'], (string) $digest ) ) { return null; } return $pick; } /** * Whether WeChat answered a draft request, so that nothing was created: an errcode, or a * failure before the request. A transport error or a timeout leaves the result unknown. * * @param WP_Error $error The error. * @return bool */ private static function answer_known( $error ) { $code = (string) $error->get_error_code(); return (bool) preg_match( '/^wx_\d+$/', $code ) || in_array( $code, array( 'wx_secret', 'wx_token', 'wx_nothing' ), true ); } /** * The scheduled run's one candidate: the post picked for today, while it still qualifies and * its review still binds the article readers would get. No pick, no draft. * * @param array|null $options Settings. * @return int[] */ public static function daily_candidates( $options = null ) { $options = self::options( $options ); $pick = self::pick_for( AI_Chat_Bedrock_WeChat::app_id( $options ), self::today() ); if ( ! $pick || empty( $pick['post_id'] ) ) { return array(); } $post = get_post( (int) $pick['post_id'] ); if ( ! $post instanceof WP_Post || self::sent( $post->ID ) || '' !== self::shortfall( $post, $options ) ) { return array(); } $snapshot = self::review_snapshot( $post, $options ); if ( is_wp_error( $snapshot ) || ! isset( $pick['review_digest'] ) || ! hash_equals( (string) $pick['review_digest'], $snapshot['digest'] ) ) { return array(); } return array( (int) $post->ID ); } /** * The day's state, in words for the settings screen. * * @return string Empty while drafts are off. */ public static function daily_summary() { $options = self::options( null ); if ( ! self::enabled( $options ) ) { return ''; } $account_id = AI_Chat_Bedrock_WeChat::app_id( $options ); $date = self::today(); $ledger = self::ledger(); $pick = self::pick_for( $account_id, $date ); $days = isset( $ledger['drafts'][ $account_id ] ) && is_array( $ledger['drafts'][ $account_id ] ) ? $ledger['drafts'][ $account_id ] : array(); $parts = array(); if ( $pick && ! empty( $pick['post_id'] ) ) { /* translators: 1: today's date, 2: post ID. */ $parts[] = sprintf( __( 'Today (%1$s, Singapore time): post %2$s is picked for the WeChat draft.', 'ai-chat-for-amazon-bedrock' ), $date, number_format_i18n( (int) $pick['post_id'] ) ); } else { /* translators: %s: today's date. */ $parts[] = sprintf( __( 'Today (%s, Singapore time): no post is picked, so the scheduled run makes no draft.', 'ai-chat-for-amazon-bedrock' ), $date ); } if ( isset( $days[ $date ]['status'] ) ) { /* translators: %s: reserved, accepted, unknown, refused or nothing. */ $parts[] = sprintf( __( 'Today\'s draft: %s.', 'ai-chat-for-amazon-bedrock' ), $days[ $date ]['status'] ); } $unknown = 0; foreach ( $days as $day => $entry ) { if ( $day !== $date && isset( $entry['status'] ) && in_array( $entry['status'], array( 'reserved', 'unknown' ), true ) ) { ++$unknown; } } if ( $unknown ) { /* translators: %s: number of days. */ $parts[] = sprintf( _n( '%s earlier day\'s draft has no known result; reconcile it in the Official Accounts Platform before new drafts are made.', '%s earlier days\' drafts have no known result; reconcile them in the Official Accounts Platform before new drafts are made.', $unknown, 'ai-chat-for-amazon-bedrock' ), number_format_i18n( $unknown ) ); } return implode( ' ', $parts ); } /** * Whether a post may go to the account: published and readable by anyone. * * @param WP_Post|null $post Post. * @return bool */ public static function sendable( $post ) { return AI_Chat_Bedrock_Ontology::allows( $post, 'distribution' ); } /** * Make a draft of up to eight posts, and note it in their publishing records. * * @param int[] $post_ids Posts, in the order of the draft. * @param array $options Settings. * @param string $source manual, agent or schedule. * @return array|WP_Error media_id, posts sent, and posts skipped with the reason. */ public static function create( $post_ids, $options = null, $source = 'manual' ) { $options = self::options( $options ); $articles = array(); $posts = array(); $skipped = array(); $automatic = 'manual' !== $source; $reviewed = array(); self::$images_left = self::DRAFT_IMAGES; self::$images_until = microtime( true ) + self::IMAGE_SECONDS; if ( ! self::enabled( $options ) ) { return self::note( new WP_Error( 'wx_secret', __( 'Drafts for the WeChat Official Account are off, or its AppID or AppSecret is missing.', 'ai-chat-for-amazon-bedrock' ) ), $source ); } $ids = array_slice( array_values( array_unique( array_map( 'absint', (array) $post_ids ) ) ), 0, self::MAX_ARTICLES ); // A new draft is the account's one draft for the day, whichever way it is made; replacing // the article in a post's own current draft is not. The day is taken before anything is // uploaded, and settled with WeChat's answer below. $reservation = null; if ( 1 !== count( $ids ) || null === self::draft_of( $ids[0] ) ) { $reservation = self::reserve( $options, $ids, $source ); if ( is_wp_error( $reservation ) ) { return self::note( $reservation, $source ); } } foreach ( $ids as $post_id ) { $post = get_post( $post_id ); if ( ! self::sendable( $post ) ) { $skipped[ $post_id ] = 'not_public'; continue; } if ( $automatic ) { $reason = self::shortfall( $post, $options ); if ( '' !== $reason ) { $skipped[ $post_id ] = $reason; continue; } // Existing effects are updated only on their own, after native reconciliation. if ( self::sent( $post_id ) ) { $draft = self::draft_of( $post_id ); if ( 1 !== count( $post_ids ) || null === $draft ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_reconcile' ) ); return self::note( new WP_Error( 'wx_reconcile', 'Existing WeChat record requires reconciliation.' ), $source ); } self::$draft_times = null; if ( 'current' !== self::draft_state( $draft, $options ) ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_reconcile' ) ); return self::note( new WP_Error( 'wx_reconcile', 'Native draft is missing, unknown or edited; reconcile before updating.' ), $source ); } } // Native readback may take time: validate again before the first upload. $current = get_post( $post_id ); $snapshot = self::review_snapshot( $current, $options ); if ( is_wp_error( $snapshot ) || '' !== self::review_shortfall( $current, $options ) ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_review' ) ); return self::note( new WP_Error( 'wx_review', 'The curated review changed before conversion.' ), $source ); } if ( 'schedule' === $source && null !== $reservation && null === self::pick_binding( $options, $current, $snapshot['digest'] ) ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_pick' ) ); return self::note( new WP_Error( 'wx_pick', 'The scheduled run writes only the post picked for today, as picked.' ), $source ); } $post = $current; $reviewed[ $post_id ] = $snapshot['digest']; } $article = self::article( $post, $options ); // WeChat refusing the account, as for an address not in the IP whitelist, stops here. if ( is_wp_error( $article ) && 0 === strpos( (string) $article->get_error_code(), 'wx_' ) ) { self::settle( $reservation, 'nothing', array( 'code' => $article->get_error_code() ) ); return self::note( $article, $source, $skipped ); } if ( is_wp_error( $article ) ) { $skipped[ $post_id ] = $article->get_error_code(); continue; } $articles[] = $article; $posts[] = $post; } if ( ! $articles ) { $error = new WP_Error( 'wx_nothing', __( 'No post could be made into an article.', 'ai-chat-for-amazon-bedrock' ), array( 'skipped' => $skipped ) ); self::settle( $reservation, 'nothing', array( 'code' => 'wx_nothing' ) ); return self::note( $error, $source, $skipped ); } $pick = null; if ( $automatic ) { $current_options = self::options( null ); foreach ( $posts as $post ) { $current = get_post( $post->ID ); $snapshot = self::review_snapshot( $current, $current_options ); if ( is_wp_error( $snapshot ) || '' !== self::review_shortfall( $current, $current_options ) || ! hash_equals( $reviewed[ $post->ID ], $snapshot['digest'] ) ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_review' ) ); return self::note( new WP_Error( 'wx_review', 'The curated version changed during conversion; no draft was written.' ), $source ); } if ( 'schedule' === $source && null !== $reservation ) { $pick = self::pick_binding( $current_options, $current, $snapshot['digest'] ); if ( null === $pick ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_pick' ) ); return self::note( new WP_Error( 'wx_pick', 'The pick changed during conversion; no draft was written.' ), $source ); } } } } // A post sent again replaces its article in the draft it is still in, rather than making // a second draft; a draft already published or deleted is made anew. $media_id = ''; $earlier = 1 === count( $posts ) ? self::draft_of( $posts[0]->ID ) : null; if ( null !== $earlier ) { $updated = AI_Chat_Bedrock_WeChat_API::call( 'draft/update', wp_json_encode( array( 'media_id' => $earlier['media_id'], 'index' => $earlier['index'], 'articles' => $articles[0], ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), self::account( $options ), 30 ); // Only a draft WeChat no longer has, published or deleted, is made anew; any other // refusal is reported, so a post is never left in two drafts. if ( is_wp_error( $updated ) && ( $automatic || ! in_array( $updated->get_error_code(), self::DRAFT_GONE, true ) ) ) { return self::note( $updated, $source, $skipped ); } $media_id = is_wp_error( $updated ) ? '' : $earlier['media_id']; } if ( '' === $media_id ) { if ( null === $reservation ) { // The post's own draft is published or gone, so this is a new draft: it takes the day. $reservation = self::reserve( $options, wp_list_pluck( $posts, 'ID' ), $source ); if ( is_wp_error( $reservation ) ) { return self::note( $reservation, $source, $skipped ); } if ( 'schedule' === $source ) { $pick = self::pick_binding( self::options( null ), $posts[0], $reviewed[ $posts[0]->ID ] ); if ( null === $pick ) { self::settle( $reservation, 'nothing', array( 'code' => 'wx_pick' ) ); return self::note( new WP_Error( 'wx_pick', 'The scheduled run writes only the post picked for today, as picked.' ), $source, $skipped ); } } } // The reservation must still be this attempt's, and carry exactly what is sent, before // WeChat is asked for anything: a day cleared or settled meanwhile sends nothing. if ( ! self::bind( $reservation, $articles, $reviewed, $pick ) ) { return self::note( new WP_Error( 'wx_daily_lost', 'The day\'s reservation changed before the draft was sent; nothing was written.' ), $source, $skipped ); } $result = AI_Chat_Bedrock_WeChat_API::call( 'draft/add', wp_json_encode( array( 'articles' => $articles ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), self::account( $options ), 30 ); if ( is_wp_error( $result ) ) { // An errcode means WeChat made nothing; a lost answer may have left a draft, which // only the owner can tell from the draft box. self::settle( $reservation, self::answer_known( $result ) ? 'refused' : 'unknown', array( 'code' => $result->get_error_code() ) ); return self::note( $result, $source, $skipped ); } $media_id = isset( $result['media_id'] ) ? (string) $result['media_id'] : ''; $earlier = null; } if ( '' === $media_id ) { self::settle( $reservation, 'unknown', array( 'code' => 'wx_http' ) ); return self::note( new WP_Error( 'wx_http', 'No media_id' ), $source, $skipped ); } foreach ( $posts as $index => $post ) { AI_Chat_Bedrock_Distribution::record( $post->ID, array( 'platform' => 'wechat', 'item_id' => $media_id, 'status' => 'planned', // Where the article sits in the draft, to replace it there later. 'version' => 'idx:' . ( null !== $earlier ? $earlier['index'] : $index ), 'title' => get_the_title( $post ), 'language' => AI_Chat_Bedrock_Content::language( $post ), 'note' => __( 'In the WeChat Official Account\'s draft box; publish it from the Official Accounts Platform.', 'ai-chat-for-amazon-bedrock' ), ), 'wechat' ); } $recorded = self::settle( $reservation, 'accepted', array( 'media_id' => $media_id, 'post_ids' => array_map( 'intval', wp_list_pluck( $posts, 'ID' ) ), ) ); if ( ! $recorded ) { // WeChat has the draft and the posts' records say so, but the day's entry could not be // settled: it stays reserved, holding later days, until the owner reconciles it. return self::note( new WP_Error( 'wx_settle', 'WeChat accepted the draft, but the day\'s record could not be settled; reconcile it from the draft box.', array( 'media_id' => $media_id ) ), $source, $skipped ); } // Older drafts of a post sent on its own are stale copies: they go, unless another post // shares them. if ( ! $automatic && 1 === count( $posts ) ) { self::retire( $posts[0]->ID, $media_id, $options ); } $done = array( 'updated' => null !== $earlier, 'media_id' => $media_id, 'posts' => wp_list_pluck( $posts, 'ID' ), 'skipped' => $skipped, ); self::note( $done, $source, $skipped ); return $done; } /** * A post as a WeChat article. * * @param WP_Post $post Post. * @param array $options Settings. * @return array|WP_Error */ public static function article( $post, $options ) { $edition = metadata_exists( 'post', $post->ID, self::EDITION_META ) ? self::edition_for_post( $post ) : null; if ( is_wp_error( $edition ) ) { return $edition; } $account = self::account( $options ); $cover = self::cover( $post, $account, $edition ); if ( is_wp_error( $cover ) ) { return $cover; } $content = self::content( $post, $account, $edition ); if ( is_wp_error( $content ) ) { return $content; } return self::article_fields( $post, $options, $edition, $content, $cover ); } /** * The article the writer sends, from its content and cover. The review binds the same * fields, so what it covers is what WeChat receives. * * @param WP_Post $post Post. * @param array $options Settings. * @param array|null $edition Edition. * @param string $content Article HTML. * @param string $cover Cover media ID. * @return array */ private static function article_fields( $post, $options, $edition, $content, $cover ) { return array( 'article_type' => 'news', 'title' => null !== $edition ? $edition['record']['title'] : self::title( html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ) ), 'author' => self::author( $options ), 'digest' => null !== $edition ? $edition['record']['excerpt'] : self::digest( $post, $content ), 'content' => $content, 'content_source_url' => (string) get_permalink( $post ), 'thumb_media_id' => $cover, 'need_open_comment' => 0, 'only_fans_can_comment' => 0, ); } /** * A title within WeChat's 32 characters: a longer one ends at its last break, such as the * colon after a series name, that leaves a title of some length, so it is not cut mid-phrase. * * @param string $title Title. * @return string */ public static function title( $title ) { $title = trim( preg_replace( '/\s+/u', ' ', (string) $title ) ); if ( AI_Chat_Bedrock_Security::string_length( $title ) <= self::TITLE_CHARS ) { return $title; } $head = AI_Chat_Bedrock_Security::string_substr( $title, 0, self::TITLE_CHARS ); if ( preg_match_all( '/[,,::;;、||—]/u', $head, $breaks, PREG_OFFSET_CAPTURE ) ) { foreach ( array_reverse( $breaks[0] ) as $break ) { $kept = rtrim( substr( $head, 0, $break[1] ) ); if ( AI_Chat_Bedrock_Security::string_length( $kept ) >= 12 ) { return $kept; } } } return AI_Chat_Bedrock_Security::string_substr( $title, 0, self::TITLE_CHARS - 1 ) . '…'; } /** * The digest: a written excerpt, or else the first paragraph of the article that says * something, ended at a sentence within WeChat's 120 characters. * * Lessons often open with a video and a credit line, which make a poor digest. * * @param WP_Post $post Post. * @param string $content The article's HTML. * @return string */ public static function digest( $post, $content ) { $text = '' !== trim( (string) $post->post_excerpt ) ? trim( wp_strip_all_tags( (string) $post->post_excerpt ) ) : ''; if ( '' === $text && preg_match_all( '#
]*>(.*?)
#s', (string) $content, $paragraphs ) ) { foreach ( $paragraphs[1] as $paragraph ) { $candidate = trim( html_entity_decode( wp_strip_all_tags( $paragraph ), ENT_QUOTES, 'UTF-8' ) ); if ( AI_Chat_Bedrock_Security::string_length( $candidate ) >= 40 && false === strpos( $candidate, '▶' ) ) { $text = $candidate; break; } } } if ( '' === $text ) { $text = html_entity_decode( AI_Chat_Bedrock_Distribution::public_excerpt( $post, 120 ), ENT_QUOTES, 'UTF-8' ); } if ( AI_Chat_Bedrock_Security::string_length( $text ) <= self::DIGEST_CHARS ) { return $text; } $head = AI_Chat_Bedrock_Security::string_substr( $text, 0, self::DIGEST_CHARS ); $end = max( (int) mb_strrpos( $head, '。' ), (int) mb_strrpos( $head, '?' ), (int) mb_strrpos( $head, '!' ), (int) mb_strrpos( $head, '. ' ) ); return $end >= 20 ? AI_Chat_Bedrock_Security::string_substr( $head, 0, $end + 1 ) : AI_Chat_Bedrock_Security::string_substr( $text, 0, self::DIGEST_CHARS - 1 ) . '…'; } /** * Why a post is not good enough to send on a schedule, or an empty string. * * A person checks every draft before it is published, and a post sent by hand is the * sender's choice; the schedule, which nobody watches, sends only posts that make a full * article: published and public, with a local curated edition, cover and current review. * * @param WP_Post $post Post. * @return string The reason, or an empty string. */ public static function shortfall( $post, $options = null ) { if ( ! self::sendable( $post ) ) { return 'not_public'; } $edition = self::edition_for_post( $post ); if ( is_wp_error( $edition ) ) { return $edition->get_error_code(); } $text = html_entity_decode( wp_strip_all_tags( $edition['html'] ), ENT_QUOTES, 'UTF-8' ); if ( AI_Chat_Bedrock_Security::string_length( preg_replace( '/\s+/u', '', (string) $text ) ) < self::MIN_TEXT ) { return 'too_short'; } $review = self::review_shortfall( $post, self::options( $options ) ); if ( '' !== $review ) { return $review; } /** * Why a post should not go to the WeChat draft box on the schedule. * * @param string $reason An empty string, or the reason found so far. * @param WP_Post $post Post. */ return (string) apply_filters( 'ai_chat_bedrock_wechat_drafts_shortfall', '', $post ); } /** * Read-only version binding. No credentials, uploads or remote image requests. * * @param WP_Post $post Post. * @param array $options Settings. * @return array|WP_Error Explicit public edition input and its SHA-256. */ private static function review_snapshot( $post, $options ) { if ( ! self::sendable( $post ) ) { return self::preparation_error( 'not_public', 'Only public posts can be reviewed.', 422 ); } $edition = self::edition_for_post( $post ); if ( is_wp_error( $edition ) ) { return $edition; } $locales = array( 'zh' => 'zh_CN', 'ja' => 'ja', 'en' => 'en_US', ); $language = AI_Chat_Bedrock_Content::language( $post ); $switched = isset( $locales[ $language ] ) && function_exists( 'switch_to_locale' ) && switch_to_locale( $locales[ $language ] ); try { $html = self::clean_html( $edition['html'] ); } finally { if ( $switched ) { restore_previous_locale(); } } $input = array( 'version' => self::REVIEW_VERSION, 'post_id' => (int) $post->ID, // Private source is hashed only, never returned to the reviewer. 'source_sha256' => hash( 'sha256', (string) $post->post_content ), 'source_digest' => $edition['record']['source_digest'], 'edition' => $edition['record'], 'title' => $edition['record']['title'], 'excerpt' => $edition['record']['excerpt'], 'guest_html' => $edition['html'], 'conversion_html' => $html, 'permalink' => (string) get_permalink( $post ), 'language' => AI_Chat_Bedrock_Content::language( $post ), 'selection_language' => self::language(), 'locale' => function_exists( 'get_locale' ) ? get_locale() : '', 'author' => self::author( $options ), 'account' => AI_Chat_Bedrock_WeChat::app_id( $options ), 'video_id' => self::video_id( $post->ID ), 'cover_id' => $edition['record']['cover_id'], 'assets' => $edition['assets'], ); // The review binds what readers get, not the code that made it: the article the writer // would send, rendered by the writer's own code with each image standing in as its // bytes' hash, since WeChat's address is only known after upload. A code change that // alters the article needs a fresh review; one that changes nothing a reader sees keeps it. $article = self::content( $post, array(), $edition, static function ( $src ) { $file = self::local_image( $src, self::IMAGE_BYTES ); return null === $file ? '' : 'https://image.invalid/' . hash_file( 'sha256', $file['path'] ); } ); if ( is_wp_error( $article ) ) { return $article; } $input['article'] = self::article_fields( $post, $options, $edition, $article, 'cover:' . $edition['record']['cover_id'] ); $json = wp_json_encode( $input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) ) { return new WP_Error( 'review_input', 'Review input could not be encoded.' ); } return array( 'digest' => hash( 'sha256', $json ), 'input' => $input, ); } /** Preparation failures are actionable input errors, including on read-only abilities. */ private static function preparation_error( $code, $message, $status = 422 ) { return new WP_Error( $code, $message, array( 'status' => $status, 'shortfall' => array( 'code' => $code, 'message' => $message, ), ) ); } /** Hash canonical source without running blocks, filters or private shortcodes. */ private static function edition_source_digest( $post ) { return hash( 'sha256', wp_json_encode( array( (int) $post->ID, (string) $post->post_content, (string) $post->post_title, (string) $post->post_excerpt, AI_Chat_Bedrock_Content::language( $post ), (string) get_permalink( $post ), ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); } /** Static authoring subset; attachment:ID is resolved only from local uploads. */ private static function edition_html( $html ) { if ( ! is_string( $html ) || '' === trim( $html ) || ! preg_match( '//u', $html ) ) { return self::preparation_error( 'edition_invalid', 'Edition HTML must be nonempty UTF-8 text.' ); } if ( strlen( $html ) > self::MAX_BYTES ) { return self::preparation_error( 'review_input_large', 'Edition HTML exceeds 1 MB.', 413 ); } $decoded = html_entity_decode( $html, ENT_QUOTES, 'UTF-8' ); if ( preg_match( '#<\s*/?\s*(script|style|iframe|form|video|audio|svg|object|embed|noscript)\b|\[/?[a-z_][^\]]*\]|#s', '', $html ); // A folded section is shown open: its toggle's label goes when a heading saying the same // follows, and is a bold line otherwise. $html = preg_replace_callback( '#' . esc_html( $label ) . '
' ) . ( isset( $found[2] ) ? $found[2] : '' ); }, $html ); // A button, such as signing in with Google, does nothing in WeChat and goes whole. $html = preg_replace( '#]*(?:wp-login\.php|class="[^"]*(?:\bbtn\b|_btn\b|-btn\b|\bbutton\b|button__link|wp-element-button)))[^>]*>.*?#is', '', $html ); // A link that is a whole item or paragraph, such as a quiz or an online lab, is opened // from Read more, since WeChat does not open it; the reader is told so. $hint = esc_html__( '(tap "Read more" at the end to open it)', 'ai-chat-for-amazon-bedrock' ); $lone = '\s*]*>((?:(?!?a\b).)*?)\s*'; // A list of links only, such as further reading, gets the hint once, after it. $html = preg_replace_callback( '#<(ul|ol)\b[^>]*>((?:\s*' . $hint . '
'; }, $html ); $html = preg_replace( '#<(li|p)\b([^>]*)>' . $lone . '\1>#is', '<$1$2>$3 ' . $hint . '$1>', $html ); // WeChat does not open links outside it, so their text stays and the link goes. $html = preg_replace( '#]*>(.*?)#is', '$1', $html ); $plain = array(); $allowed = array( 'img' => array( 'src' => true, 'alt' => true, ), ); foreach ( array( 'p', 'br', 'h2', 'h3', 'h4', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr' ) as $tag ) { $plain[ $tag ] = array(); } $allowed += $plain + array( 'th' => array( 'colspan' => true ), 'td' => array( 'colspan' => true ), 'h1' => array(), ); $html = wp_kses( $html, $allowed ); // Code keeps its spacing; everything else loses the white space between tags, which // WeChat shows as empty list items and stray blank lines. $code = array(); $html = preg_replace_callback( '#.*?#s', function ( $block ) use ( &$code ) { $code[] = $block[0]; return "\x1A" . ( count( $code ) - 1 ) . "\x1A"; }, $html ); $html = preg_replace( '#>\s+<#u', '><', $html ); $html = preg_replace( '#\s{2,}#u', ' ', $html ); $html = preg_replace( '#<(p|li|h2|h3|h4|blockquote|td|th|figcaption)>\s+#u', '<$1>', $html ); $html = preg_replace( '#\s+(p|li|h2|h3|h4|blockquote|td|th|figcaption)>#u', '$1>', $html ); $html = preg_replace( '#(
', '
' ), $html ); // Text left between blocks, as from a removed wrapper, becomes a paragraph of its own. $blocks = 'p|h2|h3|h4|ul|ol|blockquote|pre|table|hr|img|figure'; $html = preg_replace_callback( '#(^|(?:' . $blocks . ')>|' . trim( $found[2] ) . '
'; }, $html ); $html = preg_replace_callback( "#\x1A(\d+)\x1A#", function ( $found ) use ( $code ) { return isset( $code[ (int) $found[1] ] ) ? $code[ (int) $found[1] ] : ''; }, $html ); return trim( self::without_empty( $html ) ); } /** * HTML without empty paragraphs, list items, lists and headings, however deep. * * @param string $html HTML. * @return string */ private static function without_empty( $html ) { do { $before = $html; $html = preg_replace( '#<(p|li|h2|h3|h4|blockquote|strong|b|em|i|u|ul|ol)(?:\s[^>]*)?>(?:\s| |\xC2\xA0|', '
', $html ); $html = str_replace( '
', '', $html );
return preg_replace_callback(
'#<(p|h2|h3|h4|ul|ol|li|blockquote|pre|code|table|th|td|hr)(\s+colspan="\d+")?>#',
function ( $tag ) {
return '<' . $tag[1] . ( isset( $tag[2] ) ? $tag[2] : '' ) . ' style="' . self::STYLES[ $tag[1] ] . '">';
},
$html
);
}
/**
* Upload an image in the text, and return the address WeChat gives it.
*
* @param string $src Image address in the post.
* @param array $account Account.
* @param int $post_id Post, whose uploads are remembered.
* @return string Address, or an empty string when the image cannot be used.
*/
private static function upload_image( $src, $account, $post_id, $local_only = false ) {
$seen = get_post_meta( $post_id, self::IMAGES_META, true );
$seen = is_array( $seen ) ? $seen : array();
$file = self::local_image( $src, self::IMAGE_BYTES );
if ( $local_only && null === $file ) {
return '';
}
$key = md5( $account['app_id'] . '|' . ( null !== $file ? $file['path'] . '|' . hash_file( 'sha256', $file['path'] ) : 'url|' . $src ) );
if ( isset( $seen[ $key ] ) ) {
return (string) $seen[ $key ];
}
// Past the draft's share of images or time, the rest are left out.
if ( self::$images_left < 1 || ( self::$images_until && microtime( true ) > self::$images_until ) ) {
return '';
}
$file = null !== $file || $local_only ? $file : self::remote_image( $src, self::IMAGE_BYTES );
if ( null === $file ) {
return '';
}
--self::$images_left;
$form = AI_Chat_Bedrock_WeChat_API::multipart( $file['path'], $file['mime'] );
$sent = null === $form ? null : AI_Chat_Bedrock_WeChat_API::call( 'media/uploadimg', $form['body'], $account, 20, $form['type'] );
self::done_with( $file );
if ( ! is_array( $sent ) || empty( $sent['url'] ) ) {
return '';
}
// Kept at once, so a request cut short does not upload it again.
$seen[ $key ] = (string) $sent['url'];
update_post_meta( $post_id, self::IMAGES_META, array_slice( $seen, -50, null, true ) );
return $seen[ $key ];
}
/**
* The cover: the featured image, or else the first image of the post, as a permanent image.
*
* @param WP_Post $post Post.
* @param array $account Account.
* @return string|WP_Error The media_id.
*/
private static function cover( $post, $account, $edition = null ) {
$id = null !== $edition ? $edition['record']['cover_id'] : (int) get_post_thumbnail_id( $post );
$src = null !== $edition ? $edition['assets'][0]['src'] : ( $id ? (string) wp_get_attachment_url( $id ) : '' );
$file = '' !== $src ? self::local_image( $src, self::COVER_BYTES ) : null;
// Without a featured image, the first image a signed-out visitor sees; the raw content could
// offer one from a members-only block as the account's public cover.
if ( null === $edition && null === $file && preg_match( '#
]*\ssrc="([^"]+)"#i', (string) AI_Chat_Bedrock_Content::render_as_guest( $post ), $found ) ) {
$src = html_entity_decode( $found[1], ENT_QUOTES, 'UTF-8' );
$file = self::local_image( $src, self::COVER_BYTES );
}
$key = md5( $account['app_id'] . '|' . ( null !== $file ? $file['path'] . '|' . hash_file( 'sha256', $file['path'] ) : 'url|' . $src ) );
$saved = get_post_meta( $post->ID, self::COVER_META, true );
if ( '' !== $src && is_array( $saved ) && isset( $saved['key'], $saved['media_id'] ) && $key === $saved['key'] ) {
return (string) $saved['media_id'];
}
// An image offloaded to a CDN is fetched from there.
$file = null === $edition && null === $file && '' !== $src ? self::remote_image( $src, self::COVER_BYTES ) : $file;
if ( null === $file ) {
return new WP_Error( 'no_cover', __( 'The post needs a featured image in JPEG or PNG for the cover.', 'ai-chat-for-amazon-bedrock' ) );
}
$form = AI_Chat_Bedrock_WeChat_API::multipart( $file['path'], $file['mime'] );
self::done_with( $file );
if ( null === $form ) {
return new WP_Error( 'no_cover', __( 'The post needs a featured image in JPEG or PNG for the cover.', 'ai-chat-for-amazon-bedrock' ) );
}
$sent = AI_Chat_Bedrock_WeChat_API::call( 'material/add_material?type=image', $form['body'], $account, 20, $form['type'] );
if ( is_wp_error( $sent ) ) {
return $sent;
}
if ( empty( $sent['media_id'] ) ) {
return new WP_Error( 'no_cover', __( 'WeChat did not take the cover image.', 'ai-chat-for-amazon-bedrock' ) );
}
update_post_meta(
$post->ID,
self::COVER_META,
array(
'key' => $key,
'media_id' => (string) $sent['media_id'],
)
);
return (string) $sent['media_id'];
}
/**
* An image from another host, such as a CDN the site's media is offloaded to, fetched into a
* temporary file: https only, through WordPress's safe HTTP API, which refuses addresses on
* the server's own network, and only when it is a JPEG or PNG within the size.
*
* @param string $src Image address.
* @param int $bytes Largest size.
* @return array|null path, mime, and temp to delete it after.
*/
public static function remote_image( $src, $bytes ) {
$src = esc_url_raw( (string) $src, array( 'https' ) );
if ( '' === $src || ! function_exists( 'wp_tempnam' ) ) {
return null;
}
$response = wp_safe_remote_get(
$src,
array(
'timeout' => 15,
'redirection' => 2,
'limit_response_size' => $bytes + 1,
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = (string) wp_remote_retrieve_body( $response );
$info = '' !== $data && strlen( $data ) <= $bytes && function_exists( 'getimagesizefromstring' ) ? getimagesizefromstring( $data ) : false;
$mime = is_array( $info ) && in_array( $info['mime'], array( 'image/jpeg', 'image/png' ), true ) ? $info['mime'] : '';
if ( '' === $mime ) {
return null;
}
$path = wp_tempnam( 'aicfab-wechat' );
if ( ! $path || false === file_put_contents( $path, $data ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- a temporary file, deleted after the upload.
return null;
}
return array(
'path' => $path,
'mime' => $mime,
'temp' => true,
);
}
private static function done_with( $file ) {
if ( ! empty( $file['temp'] ) && is_file( $file['path'] ) ) {
wp_delete_file( $file['path'] );
}
}
/**
* The file of an image in the uploads folder, in JPEG or PNG and small enough; a large one
* is replaced by its large or medium size.
*
* @param string $src Image address.
* @param int $bytes Largest size.
* @return array|null path and mime.
*/
public static function local_image( $src, $bytes ) {
$uploads = wp_get_upload_dir();
$base = preg_replace( '#^https?:#i', '', (string) $uploads['baseurl'] );
$src = preg_replace( '#^https?:#i', '', strtok( (string) $src, '?#' ) );
if ( '' === $base || 0 !== strpos( $src, $base . '/' ) ) {
return null;
}
$relative = rawurldecode( substr( $src, strlen( $base ) + 1 ) );
$root = realpath( $uploads['basedir'] );
$path = realpath( $uploads['basedir'] . '/' . $relative );
if ( false === $root || false === $path || 0 !== strpos( $path, $root . DIRECTORY_SEPARATOR ) || ! is_file( $path ) ) {
return null;
}
$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
$mime = in_array( $ext, array( 'jpg', 'jpeg' ), true ) ? 'image/jpeg' : ( 'png' === $ext ? 'image/png' : '' );
clearstatcache( true, $path );
if ( '' !== $mime && filesize( $path ) <= $bytes ) {
return array(
'path' => $path,
'mime' => $mime,
);
}
// A smaller size of the same upload, made by WordPress.
$id = attachment_url_to_postid( $uploads['baseurl'] . '/' . $relative );
foreach ( $id ? array( 'large', 'medium_large', 'medium' ) : array() as $size ) {
$smaller = wp_get_attachment_image_src( $id, $size );
if ( is_array( $smaller ) && ! empty( $smaller[0] ) && ! empty( $smaller[3] ) ) {
$found = self::local_image( $smaller[0], $bytes );
if ( null !== $found ) {
return $found;
}
}
}
return null;
}
/**
* The scheduled run: the newest featured posts not sent before, as one draft.
*/
public static function run() {
$options = self::options( null );
if ( ! self::enabled( $options ) || 'off' === self::schedule( $options ) ) {
return;
}
// One run at a time: WP-Cron can start a second while the first is uploading, which
// would make a second draft of the same post. A lock left by a run that died is taken
// over once it has expired.
$lock = AI_Chat_Bedrock_Security::acquire_lock( self::LOCK_OPTION, self::LOCK_TTL );
if ( '' === $lock ) {
return;
}
try {
self::run_locked( $options );
} finally {
AI_Chat_Bedrock_Security::release_lock( self::LOCK_OPTION, $lock );
}
}
/**
* The scheduled run itself, under the lock.
*
* @param array $options Settings.
*/
private static function run_locked( $options ) {
$refreshed = 0;
self::$draft_times = null;
self::$held = array();
if ( ! empty( $options['wechat_drafts_sync'] ) ) {
$refreshed = self::refresh_changed( $options );
self::tell_held( $options );
}
// The day's one curated article: the post picked for today, or nothing.
$ids = self::daily_candidates( $options );
if ( ! $ids ) {
// A refresh that could not ask WeChat has noted why; that stays on the settings screen.
if ( 0 === $refreshed ) {
self::note( array( 'nothing' => true ), 'schedule' );
}
return;
}
// One draft for each post, as when sent by hand: each is published on its own, in order,
// and updated in place later.
$titles = array();
foreach ( array_reverse( $ids ) as $id ) {
$done = self::create( array( $id ), $options, 'schedule' );
// WeChat refusing the account stops the run; the reason is on the settings screen.
if ( is_wp_error( $done ) && 0 === strpos( (string) $done->get_error_code(), 'wx_' ) && 'wx_nothing' !== $done->get_error_code() ) {
break;
}
if ( ! is_wp_error( $done ) ) {
$titles[] = '· ' . html_entity_decode( get_the_title( $id ), ENT_QUOTES, 'UTF-8' );
}
}
if ( ! $titles || empty( $options['wechat_drafts_notify'] ) ) {
return;
}
wp_mail(
get_option( 'admin_email' ),
/* translators: %d: number of articles. */
sprintf( _n( '%d article is ready in the WeChat draft box', '%d articles are ready in the WeChat draft box', count( $titles ), 'ai-chat-for-amazon-bedrock' ), count( $titles ) ),
implode( "\n", $titles ) . "\n\n" . __( 'Check them and publish in the WeChat Official Accounts Platform: https://mp.weixin.qq.com/', 'ai-chat-for-amazon-bedrock' )
);
}
/**
* Replace the articles of drafts whose post changed since it was sent, such as a lesson
* that gained a quiz, while they are still drafts.
*
* @param array $options Settings.
* @return int Drafts updated, or -1 when WeChat could not be asked.
*/
public static function refresh_changed( $options ) {
$ids = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 50,
'fields' => 'ids',
'orderby' => 'modified',
'order' => 'DESC',
'no_found_rows' => true,
'date_query' => array(
array(
'column' => 'post_modified_gmt',
'after' => self::RECENT_DAYS . ' days ago',
),
),
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- once a scheduled run, for posts sent to WeChat.
array(
'key' => AI_Chat_Bedrock_Distribution::META,
'value' => '"wechat"',
'compare' => 'LIKE',
),
),
)
);
$done = 0;
foreach ( (array) $ids as $id ) {
$draft = self::draft_of( (int) $id );
$post = get_post( (int) $id );
if ( $done >= self::MAX_ARTICLES || null === $draft || ! $post instanceof WP_Post ) {
continue;
}
if ( strtotime( $post->post_modified_gmt . ' UTC' ) <= $draft['updated_at'] || '' !== self::shortfall( $post, $options ) ) {
continue;
}
$state = self::draft_state( $draft, $options );
// WeChat not saying, as with an address not in the IP whitelist, stops the refresh.
if ( 'unknown' === $state ) {
self::note( self::$draft_times, 'schedule' );
self::$held = array();
return -1;
}
// Absence does not prove publication or deletion. Preserve the original effect.
if ( 'gone' === $state ) {
self::note( new WP_Error( 'wx_reconcile', 'Native draft is missing; preserve its record and reconcile.' ), 'schedule' );
return -1;
}
// A draft its owner edited in WeChat, as by inserting a video, is theirs now.
if ( 'edited' === $state ) {
self::$held[ $post->ID ] = $post->ID;
continue;
}
$result = self::create( array( $post->ID ), $options, 'schedule' );
// WeChat refusing the account stops the run; a post it refuses is left for later.
if ( is_wp_error( $result ) && 0 === strpos( (string) $result->get_error_code(), 'wx_' ) && 'wx_nothing' !== $result->get_error_code() ) {
break;
}
$done += is_wp_error( $result ) ? 0 : 1;
}
return $done;
}
/**
* Email the site about posts that changed while their drafts hold edits made in WeChat.
*
* Each post is told about once for each change of the post.
*
* @param array $options Settings.
*/
private static function tell_held( $options ) {
$told = get_option( self::HELD_OPTION, array() );
$told = is_array( $told ) ? $told : array();
$titles = array();
foreach ( self::$held as $post_id ) {
$post = get_post( $post_id );
$changed = $post instanceof WP_Post ? (string) $post->post_modified_gmt : '';
if ( '' === $changed || ( isset( $told[ $post_id ] ) && $told[ $post_id ] === $changed ) ) {
continue;
}
$told[ $post_id ] = $changed;
$titles[] = '· ' . html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ) . ' — ' . get_edit_post_link( $post_id, 'url' );
}
// Remembered only once told, so turning emails on later still tells of them.
if ( ! $titles || empty( $options['wechat_drafts_notify'] ) ) {
return;
}
update_option( self::HELD_OPTION, array_slice( $told, -200, null, true ), false );
wp_mail(
get_option( 'admin_email' ),
/* translators: %d: number of posts. */
sprintf( _n( '%d post changed, and its WeChat draft holds your edits', '%d posts changed, and their WeChat drafts hold your edits', count( $titles ), 'ai-chat-for-amazon-bedrock' ), count( $titles ) ),
implode( "\n", $titles ) . "\n\n" . __( 'These drafts were edited in WeChat, so they were not replaced. Update them in the Official Accounts Platform, or send the post again from its edit screen to replace the draft, edits included.', 'ai-chat-for-amazon-bedrock' )
);
}
/**
* Whether a draft was changed in WeChat after the plugin last sent it.
*
* WeChat's draft list gives each draft's last update; the plugin's own update sets it
* too, so only a later one, with two minutes to spare, is the owner's.
*
* @param array $draft From draft_of().
* @param array $options Settings.
* @return bool True also when WeChat cannot say, so nothing is replaced on a guess.
*/
public static function edited_in_wechat( $draft, $options ) {
return 'current' !== self::draft_state( $draft, $options );
}
/**
* What became of a draft in WeChat since the plugin sent it.
*
* @param array $draft From draft_of().
* @param array $options Settings.
* @return string current: as sent; edited: changed in WeChat; gone: no longer a draft,
* published or deleted; unknown: WeChat could not be asked.
*/
public static function draft_state( $draft, $options ) {
if ( null === self::$draft_times ) {
self::$draft_times = self::draft_times( $options );
}
if ( is_wp_error( self::$draft_times ) ) {
return 'unknown';
}
if ( ! isset( self::$draft_times[ $draft['media_id'] ] ) ) {
return 'gone';
}
return self::$draft_times[ $draft['media_id'] ] > $draft['updated_at'] + self::EDIT_SLACK ? 'edited' : 'current';
}
/**
* The last update of each draft in the account, without its content.
*
* @param array $options Settings.
* @return array|WP_Error media_id to time.
*/
public static function draft_times( $options ) {
$times = array();
for ( $offset = 0; $offset < 200; $offset += 20 ) {
$page = AI_Chat_Bedrock_WeChat_API::call(
'draft/batchget',
wp_json_encode(
array(
'offset' => $offset,
'count' => 20,
'no_content' => 1,
)
),
self::account( $options ),
15
);
if ( is_wp_error( $page ) ) {
return $page;
}
$items = isset( $page['item'] ) && is_array( $page['item'] ) ? $page['item'] : array();
foreach ( $items as $item ) {
if ( is_array( $item ) && ! empty( $item['media_id'] ) ) {
$times[ (string) $item['media_id'] ] = isset( $item['update_time'] ) ? (int) $item['update_time'] : 0;
}
}
if ( count( $items ) < 20 ) {
break;
}
}
return $times;
}
/**
* When the scheduled run is next due, and whether WordPress's scheduler is running.
*
* @return string Empty while there is no schedule.
*/
public static function schedule_summary() {
$next = wp_next_scheduled( self::CRON );
if ( ! $next ) {
return '';
}
/* translators: %s: date and time of the next run. */
$line = sprintf( __( 'Next scheduled run: %s.', 'ai-chat-for-amazon-bedrock' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) );
// A run long overdue means WP-Cron is not firing, as on a site with no visits or with
// DISABLE_WP_CRON set and no system cron calling wp-cron.php.
if ( $next < time() - 15 * MINUTE_IN_SECONDS ) {
$line .= ' ' . __( 'It is overdue: WordPress\'s scheduler is not running. Have a system cron job request wp-cron.php every few minutes.', 'ai-chat-for-amazon-bedrock' );
}
return $line;
}
/**
* Run the scheduled task now, from the settings screen.
*/
public static function handle_run_now() {
check_admin_referer( 'aicfab_wechat_drafts_run' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You cannot run this.', 'ai-chat-for-amazon-bedrock' ), 403 );
}
self::run();
wp_safe_redirect( admin_url( 'admin.php?page=ai-chat-for-amazon-bedrock-settings&tab=publishing' ) );
exit;
}
/**
* Keep the scheduled run in step with the settings.
*/
public static function sync_schedule() {
$options = self::options( null );
$want = self::enabled( $options ) && 'off' !== self::schedule( $options ) ? self::schedule( $options ) : '';
$next = wp_next_scheduled( self::CRON );
if ( '' === $want ) {
if ( $next ) {
wp_clear_scheduled_hook( self::CRON );
}
return;
}
if ( $next && wp_get_schedule( self::CRON ) === $want ) {
return;
}
wp_clear_scheduled_hook( self::CRON );
wp_schedule_event( self::first_run(), $want, self::CRON );
}
/**
* Nine in the morning, site time, next: when an owner is likely to look.
*
* @return int Timestamp.
*/
public static function first_run() {
$zone = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( 'UTC' );
$next = new DateTime( 'today 09:00', $zone );
if ( $next->getTimestamp() <= time() ) {
$next->modify( '+1 day' );
}
return $next->getTimestamp();
}
/**
* Send one post from its edit screen.
*/
public static function handle_send() {
$post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- checked next.
check_admin_referer( 'aicfab_wechat_draft_' . $post_id );
if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( 'publish_posts' ) ) {
wp_die( esc_html__( 'You cannot send this post.', 'ai-chat-for-amazon-bedrock' ), 403 );
}
$done = self::create( array( $post_id ), null, 'manual' );
set_transient( 'aicfab_wechat_draft_notice_' . get_current_user_id(), is_wp_error( $done ) ? self::describe( $done ) : ( ! empty( $done['updated'] ) ? __( 'The post\'s article in the WeChat draft box was replaced with the current version.', 'ai-chat-for-amazon-bedrock' ) : __( 'The post is in the WeChat draft box. Publish it from the Official Accounts Platform.', 'ai-chat-for-amazon-bedrock' ) ), 300 );
wp_safe_redirect( (string) get_edit_post_link( $post_id, 'url' ) );
exit;
}
/**
* The editor's part of the Published elsewhere box.
*
* @param WP_Post $post Post.
*/
public static function render_box_section( $post ) {
if ( ! self::enabled() || ! current_user_can( 'publish_posts' ) ) {
return;
}
$notice = get_transient( 'aicfab_wechat_draft_notice_' . get_current_user_id() );
if ( is_string( $notice ) && '' !== $notice ) {
delete_transient( 'aicfab_wechat_draft_notice_' . get_current_user_id() );
echo '' . esc_html( $notice ) . '
';
}
if ( ! self::sendable( $post ) ) {
echo '' . esc_html__( 'Published posts that anyone can read can be sent to the WeChat draft box.', 'ai-chat-for-amazon-bedrock' ) . '
';
return;
}
$label = self::sent( $post->ID ) ? __( 'Send to the WeChat draft box again', 'ai-chat-for-amazon-bedrock' ) : __( 'Send to the WeChat draft box', 'ai-chat-for-amazon-bedrock' );
echo '';
if ( null !== self::draft_of( $post->ID ) ) {
echo '' . esc_html__( 'Sending it again replaces its draft with the current version, including any changes made to it in WeChat. The scheduled update leaves drafts edited in WeChat alone.', 'ai-chat-for-amazon-bedrock' ) . '
';
}
wp_nonce_field( 'aicfab_wechat_video_' . $post->ID, 'aicfab_wechat_video_nonce' );
echo '';
echo '' . esc_html__( 'WeChat\'s API takes videos of at most 10 MB and cannot place one in an article. Upload the post\'s video in the Official Accounts Platform, enter the ID it gets, wxv_ and digits, and save the post: each draft then shows WeChat\'s player where the video is.', 'ai-chat-for-amazon-bedrock' ) . '
';
}
/**
* Register the ability agents use to send posts.
*/
public function register_abilities() {
if ( ! function_exists( 'wp_register_ability' ) ) {
return;
}
foreach ( array(
'get-wechat-review' => 'ability_get_review',
'review-wechat-post' => 'ability_review',
'get-wechat-edition' => 'ability_get_edition',
'set-wechat-edition' => 'ability_set_edition',
) as $name => $callback ) {
$readonly = in_array( $callback, array( 'ability_get_review', 'ability_get_edition' ), true );
$edition = in_array( $callback, array( 'ability_get_edition', 'ability_set_edition' ), true );
wp_register_ability(
'ai-chat-bedrock/' . $name,
array(
'label' => $edition ? ( $readonly ? __( 'Read the public WeChat edition', 'ai-chat-for-amazon-bedrock' ) : __( 'Prepare a public WeChat edition', 'ai-chat-for-amazon-bedrock' ) ) : ( $readonly ? __( 'Read the current WeChat review input', 'ai-chat-for-amazon-bedrock' ) : __( 'Record a curated WeChat review', 'ai-chat-for-amazon-bedrock' ) ),
'description' => $edition ? __( 'Read or prepare a protected static public edition for an existing post. Requires permission to publish posts and edit the post. Does not contact WeChat or change the canonical post.', 'ai-chat-for-amazon-bedrock' ) : __( 'Read the public version binding or record an editorial attestation for that exact digest. Requires permission to publish posts and edit the post. Does not contact WeChat.', 'ai-chat-for-amazon-bedrock' ),
'input_schema' => $edition ? self::edition_schema( ! $readonly ) : self::review_schema( ! $readonly ),
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => array( $this, $callback ),
'permission_callback' => array( $this, 'can_review' ),
'category' => AI_Chat_Bedrock_Abilities::CATEGORY,
'meta' => array(
'annotations' => array(
'readonly' => $readonly,
'destructive' => false,
'idempotent' => $readonly,
),
'public' => true,
),
)
);
}
wp_register_ability(
'ai-chat-bedrock/get-wechat-pick',
array(
'label' => __( 'Read the day\'s WeChat pick', 'ai-chat-for-amazon-bedrock' ),
'description' => __( 'The post picked for the WeChat Official Account on a day (today by default, or tomorrow, Singapore date) and that day\'s draft record, if any. Reads the ledger only; does not contact WeChat. Requires permission to publish posts.', 'ai-chat-for-amazon-bedrock' ),
'input_schema' => self::pick_read_schema(),
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => array( $this, 'ability_get_pick' ),
'permission_callback' => array( $this, 'can_read_pick' ),
'category' => AI_Chat_Bedrock_Abilities::CATEGORY,
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'public' => true,
),
)
);
wp_register_ability(
'ai-chat-bedrock/pick-wechat-post',
array(
'label' => __( 'Pick the day\'s WeChat article', 'ai-chat-for-amazon-bedrock' ),
'description' => __( 'Choose the one post the scheduled run drafts for the WeChat Official Account today (Singapore date), with the reason. The post needs its public edition and a current curated review of the exact digest given. One post per account and day; the pick does not contact WeChat.', 'ai-chat-for-amazon-bedrock' ),
'input_schema' => self::pick_schema(),
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => array( $this, 'ability_pick' ),
'permission_callback' => array( $this, 'can_review' ),
'category' => AI_Chat_Bedrock_Abilities::CATEGORY,
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => true,
),
'public' => true,
),
)
);
wp_register_ability(
'ai-chat-bedrock/reconcile-wechat-draft',
array(
'label' => __( 'Reconcile a day\'s WeChat draft', 'ai-chat-for-amazon-bedrock' ),
'description' => __( 'For a day whose draft request got no answer: say what the Official Accounts Platform shows. Accepted with the draft\'s media_id checks that draft against the reviewed articles and records it; not landed frees the account for a new draft. Reads a draft; never makes, updates or deletes one. Requires permission to manage options.', 'ai-chat-for-amazon-bedrock' ),
'input_schema' => self::reconcile_schema(),
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => array( $this, 'ability_reconcile' ),
'permission_callback' => array( $this, 'can_reconcile' ),
'category' => AI_Chat_Bedrock_Abilities::CATEGORY,
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => true,
),
'public' => true,
),
)
);
if ( ! self::enabled() ) {
return;
}
wp_register_ability(
'ai-chat-bedrock/create-wechat-draft',
array(
'label' => __( 'Send posts to the WeChat draft box', 'ai-chat-for-amazon-bedrock' ),
'description' => __( 'Make one draft in the WeChat Official Account from up to eight published posts with current curated reviews, in order, and note it in their publishing records. Nothing is published: the owner publishes the draft in the Official Accounts Platform.', 'ai-chat-for-amazon-bedrock' ),
'input_schema' => self::schema(),
'output_schema' => array( 'type' => 'object' ),
'execute_callback' => array( $this, 'ability_create' ),
'permission_callback' => array( $this, 'can_send' ),
'category' => AI_Chat_Bedrock_Abilities::CATEGORY,
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
'public' => true,
),
)
);
}
public static function schema() {
return array(
'type' => 'object',
'properties' => array(
'post_ids' => array(
'type' => 'array',
'items' => array( 'type' => 'integer' ),
'minItems' => 1,
'maxItems' => self::MAX_ARTICLES,
),
),
'required' => array( 'post_ids' ),
);
}
/**
* Whether the caller may send these posts: publish posts, and edit each.
*
* @param array $input Input.
* @return bool
*/
public function can_send( $input = array() ) {
$ids = is_array( $input ) && isset( $input['post_ids'] ) ? (array) $input['post_ids'] : array();
if ( ! $ids || count( $ids ) > self::MAX_ARTICLES || ! current_user_can( 'publish_posts' ) ) {
return false;
}
foreach ( $ids as $id ) {
if ( ! is_int( $id ) || $id < 1 || ! current_user_can( 'edit_post', $id ) ) {
return false;
}
}
return true;
}
public function ability_create( $input ) {
if ( ! $this->can_send( $input ) ) {
return new WP_Error( 'forbidden', __( 'This account cannot send those posts.', 'ai-chat-for-amazon-bedrock' ) );
}
$done = self::create( isset( $input['post_ids'] ) ? (array) $input['post_ids'] : array(), null, 'agent' );
return is_wp_error( $done ) ? new WP_Error( $done->get_error_code(), self::describe( $done ) ) : $done;
}
public static function review_schema( $write = false ) {
$properties = array(
'post_id' => array(
'type' => 'integer',
'minimum' => 1,
),
);
if ( $write ) {
$properties['digest'] = array(
'type' => 'string',
'pattern' => '^[a-f0-9]{64}$',
);
$properties['review_identity'] = array(
'type' => 'string',
'minLength' => 3,
'maxLength' => 160,
);
$properties['evidence'] = array(
'type' => 'string',
'minLength' => 20,
'maxLength' => 2000,
);
$checks = array();
$reasons = array();
foreach ( self::REVIEW_CHECKS as $check ) {
$checks[ $check ] = array(
'type' => 'boolean',
'enum' => array( true ),
);
$reasons[ $check ] = array(
'type' => 'string',
'minLength' => 20,
'maxLength' => 2000,
);
}
$properties['checks'] = array(
'type' => 'object',
'properties' => $checks,
'required' => self::REVIEW_CHECKS,
'additionalProperties' => false,
);
$properties['reasons'] = array(
'type' => 'object',
'properties' => $reasons,
'required' => self::REVIEW_CHECKS,
'additionalProperties' => false,
);
}
return array(
'type' => 'object',
'properties' => $properties,
'required' => array_keys( $properties ),
'additionalProperties' => false,
);
}
public function can_review( $input = array() ) {
return get_current_user_id() > 0 && is_array( $input ) && isset( $input['post_id'] ) && is_int( $input['post_id'] ) && $input['post_id'] > 0 && current_user_can( 'publish_posts' ) && current_user_can( 'edit_post', $input['post_id'] );
}
public static function edition_schema( $write = false ) {
$schema = self::review_schema();
if ( $write ) {
$schema['properties'] += array(
'title' => array(
'type' => 'string',
'minLength' => 1,
'maxLength' => self::TITLE_CHARS,
),
'excerpt' => array(
'type' => 'string',
'minLength' => 1,
'maxLength' => self::DIGEST_CHARS,
),
'html' => array(
'type' => 'string',
'minLength' => 1,
'maxLength' => self::MAX_BYTES,
'description' => 'Static public editorial HTML; use src="attachment:ID" for local body images. No shortcodes or dynamic blocks.',
),
'cover_id' => array(
'type' => 'integer',
'minimum' => 1,
),
);
$schema['required'] = array_keys( $schema['properties'] );
}
return $schema;
}
public function ability_get_edition( $input ) {
$result = $this->ability_get_review( $input );
if ( ! is_wp_error( $result ) ) {
$result['edition'] = $result['input']['edition'];
}
return $result;
}
/** Protected metadata only; this never updates canonical post fields or creates a post. */
public function ability_set_edition( $input ) {
if ( ! $this->can_review( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to publish and edit this post is required.', 403 );
}
$post = get_post( $input['post_id'] );
if ( ! self::sendable( $post ) ) {
return self::preparation_error( 'not_public', 'Only public posts can have a public WeChat edition.' );
}
if ( array_diff( array_keys( $input ), array( 'post_id', 'title', 'excerpt', 'html', 'cover_id' ) ) ) {
return self::preparation_error( 'edition_invalid', 'Unexpected edition fields.' );
}
$prepared = self::prepare_edition( $input );
if ( is_wp_error( $prepared ) ) {
return $prepared;
}
$record = $prepared['record'];
$record['version'] = self::EDITION_VERSION;
$record['source_digest'] = self::edition_source_digest( $post );
$record['author_user_id'] = get_current_user_id();
$record['stored_at'] = time();
// WordPress unslashes meta. Preserve the exact sanitized public edition bytes.
if ( false === update_post_meta( $post->ID, self::EDITION_META, wp_slash( $record ) ) && get_post_meta( $post->ID, self::EDITION_META, true ) !== $record ) {
return new WP_Error( 'edition_store', 'The edition could not be stored.' );
}
return $this->ability_get_edition( array( 'post_id' => $post->ID ) );
}
/** Permission checks are repeated here for direct callers as well as ability/MCP dispatch. */
public function ability_get_review( $input ) {
if ( ! $this->can_review( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to publish and edit this post is required.', 403 );
}
$post = get_post( $input['post_id'] );
$options = self::options( null );
$snapshot = self::review_snapshot( $post, $options );
if ( is_wp_error( $snapshot ) ) {
return $snapshot;
}
$record = get_post_meta( $post->ID, self::REVIEW_META, true );
$snapshot['review'] = self::valid_attestation( $record ) ? $record : null;
$snapshot['shortfall'] = self::shortfall( $post, $options );
$pick = get_post_meta( $post->ID, self::PICK_META, true );
$snapshot['pick'] = is_array( $pick ) ? $pick : null;
return $snapshot;
}
public static function pick_schema() {
return array(
'type' => 'object',
'properties' => array(
'post_id' => array(
'type' => 'integer',
'minimum' => 1,
),
'review_digest' => array(
'type' => 'string',
'pattern' => '^[a-f0-9]{64}$',
),
'pick_identity' => array(
'type' => 'string',
'minLength' => 3,
'maxLength' => 160,
),
'reason' => array(
'type' => 'string',
'minLength' => 20,
'maxLength' => 2000,
),
'date' => array(
'type' => 'string',
'pattern' => '^\\d{4}-\\d{2}-\\d{2}$',
'description' => 'The day the pick is for, Singapore time: today (default) or tomorrow, so a pick made after 09:00 feeds the next scheduled run.',
),
),
'required' => array( 'post_id', 'review_digest', 'pick_identity', 'reason' ),
'additionalProperties' => false,
);
}
public static function pick_read_schema() {
return array(
'type' => 'object',
'properties' => array(
'date' => array(
'type' => 'string',
'pattern' => '^\\d{4}-\\d{2}-\\d{2}$',
),
),
'additionalProperties' => false,
);
}
public function can_read_pick( $input = array() ) {
return get_current_user_id() > 0 && current_user_can( 'publish_posts' ) && is_array( $input );
}
/**
* The pick and the draft record of a day, for the editorial pipeline and the owner.
*
* @param array $input date (optional; today or tomorrow, Singapore time).
* @return array|WP_Error account, date, pick, draft.
*/
public function ability_get_pick( $input ) {
if ( ! $this->can_read_pick( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to publish posts is required.', 403 );
}
$date = isset( $input['date'] ) ? $input['date'] : self::today();
if ( ! is_string( $date ) || ! in_array( $date, array( self::today(), self::today( time() + DAY_IN_SECONDS ) ), true ) ) {
return self::preparation_error( 'pick_date', 'A pick is for today or tomorrow, Singapore time.', 409 );
}
$account_id = AI_Chat_Bedrock_WeChat::app_id( self::options( null ) );
$ledger = self::ledger();
$draft = isset( $ledger['drafts'][ $account_id ][ $date ] ) && is_array( $ledger['drafts'][ $account_id ][ $date ] ) ? $ledger['drafts'][ $account_id ][ $date ] : null;
if ( is_array( $draft ) ) {
// The record for readers: no attempt id or frozen payload, which are the writer's.
$draft = array_intersect_key( $draft, array_flip( array( 'status', 'post_ids', 'source', 'at', 'media_id', 'code', 'answered_at', 'reconciled_at' ) ) );
}
return array(
'account' => $account_id,
'date' => $date,
'pick' => self::pick_for( $account_id, $date ),
'draft' => $draft,
);
}
public static function reconcile_schema() {
return array(
'type' => 'object',
'properties' => array(
'date' => array(
'type' => 'string',
'pattern' => '^\\d{4}-\\d{2}-\\d{2}$',
),
'outcome' => array(
'type' => 'string',
'enum' => array( 'accepted', 'not-landed' ),
),
'media_id' => array(
'type' => 'string',
'minLength' => 1,
'maxLength' => 200,
),
'evidence' => array(
'type' => 'string',
'minLength' => 20,
'maxLength' => 2000,
),
),
'required' => array( 'date', 'outcome', 'evidence' ),
'additionalProperties' => false,
);
}
public function can_reconcile( $input = array() ) {
return get_current_user_id() > 0 && current_user_can( 'manage_options' ) && is_array( $input ) && isset( $input['date'], $input['outcome'], $input['evidence'] );
}
/**
* Pick the one post the scheduled run drafts for the account today. The picker chooses for
* quality; the plugin checks only that the post qualifies now and binds the exact review.
*
* @param array $input post_id, review_digest, pick_identity, reason, and optionally date (today or tomorrow).
* @return array|WP_Error The pick.
*/
public function ability_pick( $input ) {
if ( ! $this->can_review( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to publish and edit this post is required.', 403 );
}
if ( ! isset( $input['review_digest'], $input['pick_identity'], $input['reason'] ) || ! is_string( $input['review_digest'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $input['review_digest'] ) || ! self::review_text( $input['pick_identity'], 3, 160 ) || ! self::review_text( $input['reason'], 20, 2000 ) ) {
return self::preparation_error( 'pick_invalid', 'A pick needs the review digest, who picked and a specific reason.' );
}
// The day's run is at 09:00; a pick made later in the day is for tomorrow. Never further.
$date = isset( $input['date'] ) ? $input['date'] : self::today();
if ( ! is_string( $date ) || ! in_array( $date, array( self::today(), self::today( time() + DAY_IN_SECONDS ) ), true ) ) {
return self::preparation_error( 'pick_date', 'A pick is for today or tomorrow, Singapore time.', 409 );
}
$options = self::options( null );
$post = get_post( $input['post_id'] );
$reason = self::shortfall( $post, $options );
if ( '' !== $reason ) {
return self::preparation_error( $reason, 'The post does not qualify for a WeChat draft.', 409 );
}
if ( self::sent( $post->ID ) ) {
return self::preparation_error( 'already_sent', 'The post was sent to the account before.', 409 );
}
$snapshot = self::review_snapshot( $post, $options );
if ( is_wp_error( $snapshot ) ) {
return $snapshot;
}
if ( ! hash_equals( $snapshot['digest'], $input['review_digest'] ) ) {
return self::preparation_error( 'review_changed', 'Pick the current version: its digest differs.', 409 );
}
if ( 'zh' !== AI_Chat_Bedrock_Content::language( $post ) && '' !== self::language() ) {
return self::preparation_error( 'wrong_language', 'The account takes the site\'s Chinese posts.', 409 );
}
$account_id = AI_Chat_Bedrock_WeChat::app_id( $options );
$lock = self::lock_ledger( self::DAILY_LOCK_WAIT_MS );
if ( '' === $lock ) {
return self::preparation_error( 'wx_daily_busy', 'Another draft or pick is being recorded.', 409 );
}
try {
$ledger = self::ledger();
$current = self::pick_for( $account_id, $date );
if ( $current && (int) $current['post_id'] !== (int) $post->ID ) {
return self::preparation_error( 'pick_taken', 'Another post is picked for this account today.', 409 );
}
$record = array(
'version' => self::PICK_VERSION,
'post_id' => (int) $post->ID,
'date' => $date,
'account' => $account_id,
'review_digest' => $input['review_digest'],
'picked_by_user_id' => get_current_user_id(),
'pick_identity' => $input['pick_identity'],
'reason' => $input['reason'],
'picked_at' => time(),
);
$ledger['picks'][ $account_id ][ $date ] = $record;
if ( ! self::save_ledger( $ledger ) ) {
return self::preparation_error( 'ledger_store', 'The pick could not be stored.', 500 );
}
update_post_meta( $post->ID, self::PICK_META, wp_slash( $record ) );
} finally {
AI_Chat_Bedrock_Security::release_lock( self::DAILY_LOCK, $lock );
}
return $record;
}
/**
* Settle a day whose draft request got no answer, from what the owner saw in the draft box.
*
* @param array $input date, outcome, media_id, evidence.
* @return array|WP_Error The day's entry.
*/
public function ability_reconcile( $input ) {
if ( ! $this->can_reconcile( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to manage options is required.', 403 );
}
if ( ! is_string( $input['date'] ) || ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/D', $input['date'] ) || ! in_array( $input['outcome'], array( 'accepted', 'not-landed' ), true ) || ! self::review_text( $input['evidence'], 20, 2000 ) ) {
return self::preparation_error( 'reconcile_invalid', 'Say which day, whether the draft is there, and what was checked.' );
}
$options = self::options( null );
$account_id = AI_Chat_Bedrock_WeChat::app_id( $options );
$ledger = self::ledger();
$entry = isset( $ledger['drafts'][ $account_id ][ $input['date'] ] ) && is_array( $ledger['drafts'][ $account_id ][ $input['date'] ] ) ? $ledger['drafts'][ $account_id ][ $input['date'] ] : null;
if ( ! $entry || ! in_array( $entry['status'], array( 'reserved', 'unknown' ), true ) ) {
return self::preparation_error( 'nothing_to_reconcile', 'That day has no draft with an unknown result.', 409 );
}
// A fresh reservation is a writer still at work: its own answer settles it, not the owner.
if ( 'reserved' === $entry['status'] && time() - (int) ( isset( $entry['at'] ) ? $entry['at'] : 0 ) < self::DAILY_GRACE ) {
return self::preparation_error( 'reservation_live', 'A draft is being made for that day right now; reconcile it only if it is still unsettled in ten minutes.', 409 );
}
$post_ids = array_map( 'intval', isset( $entry['post_ids'] ) ? (array) $entry['post_ids'] : array() );
$media_id = '';
if ( 'accepted' === $input['outcome'] ) {
$media_id = isset( $input['media_id'] ) && is_string( $input['media_id'] ) ? sanitize_text_field( $input['media_id'] ) : '';
if ( '' === $media_id || ! $post_ids ) {
return self::preparation_error( 'reconcile_invalid', 'An accepted draft needs its media_id from the Official Accounts Platform.' );
}
// Only what this attempt froze before asking WeChat can be recognised; a day whose
// request never got that far cannot have landed as the reviewed draft.
$payload = isset( $entry['payload'] ) && is_array( $entry['payload'] ) ? array_values( $entry['payload'] ) : array();
if ( count( $payload ) !== count( $post_ids ) ) {
return self::preparation_error( 'reconcile_unbound', 'No draft request with the reviewed articles was sent for that day; it cannot be recorded as accepted.', 409 );
}
$draft = AI_Chat_Bedrock_WeChat_API::call( 'draft/get', wp_json_encode( array( 'media_id' => $media_id ) ), self::account( $options ), 10 );
if ( is_wp_error( $draft ) ) {
return new WP_Error( $draft->get_error_code(), self::describe( $draft ), array( 'status' => 502 ) );
}
$items = isset( $draft['news_item'] ) && is_array( $draft['news_item'] ) ? array_values( $draft['news_item'] ) : array();
if ( count( $items ) !== count( $payload ) ) {
return self::preparation_error( 'draft_differs', 'That draft does not hold the reviewed articles.', 409 );
}
foreach ( $payload as $index => $frozen ) {
$native = is_array( $items[ $index ] ) ? self::article_identity( $items[ $index ] ) : null;
if ( null === $native || $native !== $frozen ) {
return self::preparation_error( 'draft_differs', 'That draft does not hold the reviewed articles.', 409 );
}
}
}
$lock = self::lock_ledger( self::DAILY_LOCK_WAIT_MS );
if ( '' === $lock ) {
return self::preparation_error( 'wx_daily_busy', 'Another draft or pick is being recorded.', 409 );
}
try {
$ledger = self::ledger();
$current = isset( $ledger['drafts'][ $account_id ][ $input['date'] ] ) && is_array( $ledger['drafts'][ $account_id ][ $input['date'] ] ) ? $ledger['drafts'][ $account_id ][ $input['date'] ] : null;
if ( $current !== $entry ) {
return self::preparation_error( 'reconcile_changed', 'That day\'s record changed while it was being checked; read it again.', 409 );
}
if ( '' !== $media_id ) {
foreach ( $post_ids as $index => $post_id ) {
AI_Chat_Bedrock_Distribution::record(
$post_id,
array(
'platform' => 'wechat',
'item_id' => $media_id,
'status' => 'planned',
'version' => 'idx:' . $index,
'title' => get_the_title( $post_id ),
'language' => AI_Chat_Bedrock_Content::language( get_post( $post_id ) ),
'note' => __( 'In the WeChat Official Account\'s draft box; publish it from the Official Accounts Platform.', 'ai-chat-for-amazon-bedrock' ),
),
'wechat'
);
}
$entry['media_id'] = $media_id;
}
$entry['status'] = $input['outcome'];
$entry['reconciled_at'] = time();
$entry['reconciled_by'] = get_current_user_id();
$entry['evidence'] = $input['evidence'];
$ledger['drafts'][ $account_id ][ $input['date'] ] = $entry;
if ( ! self::save_ledger( $ledger ) ) {
return self::preparation_error( 'ledger_store', 'The day\'s record could not be stored.', 500 );
}
} finally {
AI_Chat_Bedrock_Security::release_lock( self::DAILY_LOCK, $lock );
}
return $entry;
}
public function ability_review( $input ) {
if ( ! $this->can_review( $input ) ) {
return self::preparation_error( 'forbidden', 'Permission to publish and edit this post is required.', 403 );
}
$now = time();
$record = array(
'version' => self::REVIEW_VERSION,
'post_id' => $input['post_id'],
'digest' => isset( $input['digest'] ) ? $input['digest'] : null,
'reviewed_at' => $now,
'expires_at' => $now + self::REVIEW_TTL,
'reviewer_user_id' => get_current_user_id(),
'review_identity' => isset( $input['review_identity'] ) ? $input['review_identity'] : null,
'evidence' => isset( $input['evidence'] ) ? $input['evidence'] : null,
'checks' => isset( $input['checks'] ) ? $input['checks'] : null,
'reasons' => isset( $input['reasons'] ) ? $input['reasons'] : null,
);
if ( ! self::valid_attestation( $record ) ) {
return self::preparation_error( 'review_invalid', 'Every required check needs a true attestation and a specific substantive reason, with review identity and external evidence.' );
}
$snapshot = self::review_snapshot( get_post( $input['post_id'] ), self::options( null ) );
if ( is_wp_error( $snapshot ) ) {
return $snapshot;
}
if ( ! hash_equals( $snapshot['digest'], $record['digest'] ) ) {
return self::preparation_error( 'review_changed', 'Review the current version before recording its digest.', 409 );
}
// WordPress unslashes meta values. Preserve the exact audit text across storage.
if ( false === update_post_meta( $input['post_id'], self::REVIEW_META, wp_slash( $record ) ) ) {
return new WP_Error( 'review_store', 'The review could not be stored.' );
}
return $this->ability_get_review( array( 'post_id' => $input['post_id'] ) );
}
/**
* Remember the last run, without any content: when, by whom, the draft or the error.
*
* @param array|WP_Error $result Result.
* @param string $source manual, agent or schedule.
* @param array $skipped Posts skipped, with the reason.
* @return array|WP_Error The result.
*/
private static function note( $result, $source, $skipped = array() ) {
$status = array(
'time' => time(),
'source' => $source,
'skipped' => count( $skipped ),
);
if ( is_wp_error( $result ) ) {
$status['error'] = (string) $result->get_error_code();
$status['ip'] = AI_Chat_Bedrock_WeChat_API::refused_ip( $result );
} elseif ( ! empty( $result['nothing'] ) ) {
$status['nothing'] = true;
} else {
$status['media_id'] = (string) $result['media_id'];
$status['posts'] = count( $result['posts'] );
}
update_option( self::STATUS_OPTION, $status, false );
return $result;
}
/**
* An error in words, with what to do about it.
*
* @param WP_Error $error Error.
* @return string
*/
public static function describe( $error ) {
$code = $error->get_error_code();
$ip = AI_Chat_Bedrock_WeChat_API::refused_ip( $error );
if ( '' !== $ip || in_array( $code, array( 'wx_40164', 'wx_61004' ), true ) ) {
/* translators: %s: the server's IP address, or "this server's address". */
return sprintf( __( 'WeChat does not accept calls from %s. Add it to the IP whitelist under Basic Information > Developer Key in the WeChat Developers Platform.', 'ai-chat-for-amazon-bedrock' ), '' !== $ip ? $ip : __( 'this server\'s address', 'ai-chat-for-amazon-bedrock' ) );
}
$known = array(
'wx_secret' => __( 'The AppID or AppSecret is missing.', 'ai-chat-for-amazon-bedrock' ),
'wx_40125' => __( 'WeChat did not accept the AppSecret. Reset it in the WeChat Developers Platform and enter the new one.', 'ai-chat-for-amazon-bedrock' ),
'wx_40013' => __( 'WeChat did not accept the AppID.', 'ai-chat-for-amazon-bedrock' ),
'wx_40243' => __( 'The AppSecret is frozen in the WeChat Developers Platform.', 'ai-chat-for-amazon-bedrock' ),
'wx_48001' => __( 'This account may not use that WeChat interface.', 'ai-chat-for-amazon-bedrock' ),
'wx_45009' => __( 'The account reached WeChat\'s daily limit for this interface. Try again tomorrow.', 'ai-chat-for-amazon-bedrock' ),
'no_cover' => __( 'The post needs a featured image in JPEG or PNG for the cover.', 'ai-chat-for-amazon-bedrock' ),
'not_public' => __( 'Only published posts that anyone can read are sent.', 'ai-chat-for-amazon-bedrock' ),
'too_short' => __( 'The post has too little public text for an article.', 'ai-chat-for-amazon-bedrock' ),
'wx_nothing' => __( 'No post could be made into an article: each must be published, readable by anyone, and have a featured image or an image in the text, in JPEG or PNG.', 'ai-chat-for-amazon-bedrock' ),
'wx_review' => __( 'The current article needs a valid curated review before an automatic draft can be written.', 'ai-chat-for-amazon-bedrock' ),
'wx_reconcile' => __( 'The existing WeChat draft needs reconciliation. Its record was preserved; no replacement draft was created.', 'ai-chat-for-amazon-bedrock' ),
'wx_daily_done' => __( 'The Official Account already has its one draft for today, Singapore time. The next can be made tomorrow.', 'ai-chat-for-amazon-bedrock' ),
'wx_daily_unknown' => __( 'An earlier day\'s draft request got no answer. Check the draft box and reconcile that day before another draft is made.', 'ai-chat-for-amazon-bedrock' ),
'wx_daily_busy' => __( 'Another draft is being made right now. Try again in a moment.', 'ai-chat-for-amazon-bedrock' ),
'wx_daily_store' => __( 'The day could not be reserved in the database, so nothing was sent. Try again.', 'ai-chat-for-amazon-bedrock' ),
'wx_daily_lost' => __( 'The day\'s reservation was changed by another operation before the draft was sent, so nothing was written. Check the day\'s record.', 'ai-chat-for-amazon-bedrock' ),
'wx_settle' => __( 'WeChat accepted the draft, but the day\'s record could not be settled. Reconcile the day from the draft box before another draft is made.', 'ai-chat-for-amazon-bedrock' ),
'wx_pick' => __( 'The scheduled run writes only the post picked for today, as it was picked. Pick the current version again.', 'ai-chat-for-amazon-bedrock' ),
);
if ( isset( $known[ $code ] ) ) {
return $known[ $code ];
}
/* translators: %s: error code. */
return sprintf( __( 'WeChat refused the draft (%s).', 'ai-chat-for-amazon-bedrock' ), $code );
}
/**
* The last run, in words for the settings screen.
*
* @return string Empty before the first run.
*/
public static function status_summary() {
$status = get_option( self::STATUS_OPTION, array() );
if ( ! is_array( $status ) || empty( $status['time'] ) ) {
return '';
}
$ago = human_time_diff( (int) $status['time'], time() );
if ( ! empty( $status['error'] ) ) {
$error = new WP_Error( $status['error'], ! empty( $status['ip'] ) ? 'invalid ip ' . $status['ip'] : '' );
/* translators: 1: how long ago, 2: what went wrong. */
return sprintf( __( 'Last draft %1$s ago failed: %2$s', 'ai-chat-for-amazon-bedrock' ), $ago, self::describe( $error ) );
}
if ( ! empty( $status['nothing'] ) ) {
/* translators: %s: how long ago. */
return sprintf( __( 'Last scheduled run %s ago: no new featured post to send.', 'ai-chat-for-amazon-bedrock' ), $ago );
}
/* translators: 1: how long ago, 2: number of articles. */
return sprintf( _n( 'Last draft %1$s ago: %2$s article is in the WeChat draft box.', 'Last draft %1$s ago: %2$s articles are in the WeChat draft box.', (int) $status['posts'], 'ai-chat-for-amazon-bedrock' ), $ago, number_format_i18n( (int) $status['posts'] ) );
}
private static function account( $options ) {
return array(
'app_id' => AI_Chat_Bedrock_WeChat::app_id( $options ),
'secret' => self::app_secret( $options ),
'cache' => self::ACCESS_KEY,
);
}
private static function cut( $text, $length ) {
$text = trim( (string) $text );
return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $length, 'UTF-8' ) : substr( $text, 0, $length );
}
private static function options( $options ) {
if ( is_array( $options ) ) {
return $options;
}
$options = get_option( 'ai_chat_bedrock_settings', array() );
return is_array( $options ) ? $options : array();
}
}