false, 'marker' => false, 'mode' => '', 'version' => 0, 'warnings' => array(), 'raw' => '', ); $path = self::path(); if ( '' === $path || ! is_readable( $path ) ) { self::$data = $data; return $data; } $raw = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file the owner placed there; WP_Filesystem is not loaded on the front end. $data['exists'] = true; $data['raw'] = $raw; $fields = self::parse_block( $raw ); if ( null === $fields ) { /* * No marker: this is not a lock file. Saying so out loud (rather than locking, or * rather than staying quiet) is the whole reason `warnings` exists. */ $data['warnings'][] = __( 'The file exists but has no MYBOT-LOCK block, so it is not a lock file and the lock stays off.', 'aihouse' ); self::$data = $data; return $data; } $data['marker'] = true; $data['mode'] = isset( $fields['che-do'] ) ? sanitize_key( $fields['che-do'] ) : ''; $data['version'] = isset( $fields['phien-ban'] ) ? (int) $fields['phien-ban'] : 0; if ( self::BLOCK_VERSION !== $data['version'] ) { $data['warnings'][] = sprintf( /* translators: %s: version number found in the file */ __( 'The lock file was written for block version %s, which this build does not know, so the lock is treated as ON.', 'aihouse' ), (string) $data['version'] ); } if ( ! in_array( $data['mode'], array( self::MODE_LOCKED, self::MODE_OFF ), true ) ) { $data['warnings'][] = __( 'The lock file does not say "che-do: knowledge-only" or "che-do: off", so the lock is treated as ON.', 'aihouse' ); } self::$data = $data; return $data; } /** * Pull the `key: value` lines out of the MYBOT-LOCK block. * * A text scan rather than a parser, deliberately: the block is four lines the owner may edit by * hand in a file manager, and the only requirement is that the keys are read correctly and a * malformed block is reported rather than guessed at. * * @param string $raw File contents. * @return array|null Field map, or null when the marker is absent. */ protected static function parse_block( $raw ) { $lines = preg_split( '/\r\n|\r|\n/', (string) $raw ); $found = false; $out = array(); foreach ( (array) $lines as $line ) { $trimmed = trim( (string) $line ); if ( ! $found ) { if ( self::MARKER === $trimmed ) { $found = true; } continue; } // The block ends at the HTML comment terminator or at the first heading. if ( '' === $trimmed || 0 === strpos( $trimmed, '-->' ) || 0 === strpos( $trimmed, '#' ) ) { if ( 0 === strpos( $trimmed, '-->' ) || 0 === strpos( $trimmed, '#' ) ) { break; } continue; } if ( false === strpos( $trimmed, ':' ) ) { continue; } list( $key, $value ) = explode( ':', $trimmed, 2 ); $out[ strtolower( trim( $key ) ) ] = trim( $value ); } return $found ? $out : null; } /** * Whether the lock is ON for this request. * * OFF only when the file itself says so. Everything else that can be read as a lock is ON, * because the marker line is the owner's declaration and the safe reading of a declaration * this build cannot fully understand is the locked one (see the class comment). * * @return bool */ public static function is_active() { $data = self::read(); if ( ! $data['exists'] || ! $data['marker'] ) { return false; } return self::MODE_OFF !== $data['mode']; } /** * Whether the file is there at all, whatever it says. * * @return bool */ public static function is_present() { $data = self::read(); return ! empty( $data['exists'] ); } /** * Whether a message is the short greeting the lock still lets the model answer. * * The owner chose this deliberately (docs/08 muc 6.45(d)): a visitor who types "xin chào" must * not be told there is no information. The boundary is the retriever's own small-talk rule — * the same 15 characters — rather than a new list of greetings, so the two cannot disagree * about where small talk ends. * * The trade-off is written down rather than hidden: a very short real question ("bảo hành?") * is treated as small talk too. It gets a friendly reply with no facts in it, and the visitor * has to say a little more — which is a cheaper failure than a model answering from outside * knowledge. * * @param string $message Visitor's message. * @return bool */ public static function is_small_talk( $message ) { if ( ! class_exists( 'MYBOT_Chunker' ) ) { return false; } $message = (string) $message; if ( MYBOT_Chunker::length( $message ) < self::SMALL_TALK_LENGTH ) { return true; } return self::is_courtesy( $message ); } /** * Whether a message is politeness rather than a question (1.18.0). * * The length rule above caught "Hi" and missed everything polite and longer than fifteen characters, so * the lock answered **"thanks for your support"** with *"this question is outside the information this * website holds"* — a refusal to a thank-you. That is the report of 16/10/2026, and this is the rule that * fixes it. * * **Three conditions, all required**, and each one exists to keep the lock a lock: * * 1. **no digits and no `?`** — a question mark is a question, and a number is a price, a date or a * model, never politeness; * 2. **no question word** in any language (`MYBOT_Languages::QUESTION_WORDS`), because *"Hi, giá bảo hành * bao nhiêu?"* begins politely and ends as a question; * 3. **a known courtesy phrase from the start**, and every remaining word is filler (`for`, `your`, * `support`, `bạn`, `nhiều`, …). "thanks for your support" passes; "thanks, and what does it cost" * does not. * * ⚠️ **What happens when this returns true is not a normal answer.** The caller marks the request as * small talk, which switches on the lock's small-talk note — *"be warm and answer NOTHING factual"* — so a * greeting gets a greeting, and a question wearing a greeting's clothes still has nowhere to get facts. * * @param string $message Visitor's message. * @return bool */ public static function is_courtesy( $message ) { if ( ! class_exists( 'MYBOT_Languages' ) ) { return false; } $text = self::normalise( (string) $message ); if ( '' === $text ) { return false; } if ( preg_match( '/[0-9??]/u', $message ) ) { return false; } $words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $words ) || empty( $words ) ) { return false; } foreach ( MYBOT_Languages::QUESTION_WORDS as $word ) { $word = self::normalise( (string) $word ); if ( '' === $word ) { continue; } // Whole-word match, so "understand" does not hide a "do" and "cómo" does not hide a "comer". if ( in_array( $word, $words, true ) && ! self::is_filler( $word ) ) { return false; } } foreach ( MYBOT_Languages::courtesy_phrases() as $phrase ) { $phrase = self::normalise( (string) $phrase ); if ( '' === $phrase ) { continue; } $phrase_words = preg_split( '/\s+/u', $phrase, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $phrase_words ) || count( $phrase_words ) > count( $words ) ) { continue; } if ( array_slice( $words, 0, count( $phrase_words ) ) !== $phrase_words ) { continue; } $rest = array_slice( $words, count( $phrase_words ) ); if ( self::only_fillers( $rest ) ) { return true; } } return false; } /** * Whether every word in a list is courtesy filler. * * @param array $words Words. * @return bool */ protected static function only_fillers( $words ) { foreach ( (array) $words as $word ) { if ( ! self::is_filler( $word ) ) { return false; } } return true; } /** * One normalised word, tested against the filler list. * * @param string $word Normalised word. * @return bool */ protected static function is_filler( $word ) { foreach ( MYBOT_Languages::COURTESY_FILLERS as $filler ) { if ( self::normalise( (string) $filler ) === $word ) { return true; } } return false; } /** * Lower-case, no punctuation, no doubled spaces, and no Vietnamese tones. * * The tones matter: a visitor types "cảm ơn" and the phrase table holds "cảm ơn", but the same person on * a keyboard without a Vietnamese layout types "cam on" — the same thank-you, and a list that only knew * one spelling would refuse the other politely-looking message. * * @param string $value Raw text. * @return string */ protected static function normalise( $value ) { $value = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $value, 'UTF-8' ) : strtolower( (string) $value ); /* * The Vietnamese tones, as ONE map rather than two positional lists. * * ⚠️ The first version used `str_replace( array( …letters… ), array( …letters… ), $text )`, and the two * lists drifted by **one entry**: the 67th search letter (`đ`) had no 66th replacement and was replaced * with the EMPTY STRING, so "đã" became "a" and the toneless sentence the visitor actually types missed * the phrase table. Two arrays that must stay in step are two arrays that stop being in step — a map * cannot: the key and its replacement are the same line. */ $tones = array( 'à' => 'a', 'á' => 'a', 'ạ' => 'a', 'ả' => 'a', 'ã' => 'a', 'â' => 'a', 'ầ' => 'a', 'ấ' => 'a', 'ậ' => 'a', 'ẩ' => 'a', 'ẫ' => 'a', 'ă' => 'a', 'ằ' => 'a', 'ắ' => 'a', 'ặ' => 'a', 'ẳ' => 'a', 'ẵ' => 'a', 'è' => 'e', 'é' => 'e', 'ẹ' => 'e', 'ẻ' => 'e', 'ẽ' => 'e', 'ê' => 'e', 'ề' => 'e', 'ế' => 'e', 'ệ' => 'e', 'ể' => 'e', 'ễ' => 'e', 'ì' => 'i', 'í' => 'i', 'ị' => 'i', 'ỉ' => 'i', 'ĩ' => 'i', 'ò' => 'o', 'ó' => 'o', 'ọ' => 'o', 'ỏ' => 'o', 'õ' => 'o', 'ô' => 'o', 'ồ' => 'o', 'ố' => 'o', 'ộ' => 'o', 'ổ' => 'o', 'ỗ' => 'o', 'ơ' => 'o', 'ờ' => 'o', 'ớ' => 'o', 'ợ' => 'o', 'ở' => 'o', 'ỡ' => 'o', 'ù' => 'u', 'ú' => 'u', 'ụ' => 'u', 'ủ' => 'u', 'ũ' => 'u', 'ư' => 'u', 'ừ' => 'u', 'ứ' => 'u', 'ự' => 'u', 'ử' => 'u', 'ữ' => 'u', 'ỳ' => 'y', 'ý' => 'y', 'ỵ' => 'y', 'ỷ' => 'y', 'ỹ' => 'y', 'đ' => 'd', ); $value = strtr( $value, $tones ); $value = preg_replace( '/[^\p{L}\p{N} ]+/u', ' ', $value ); return trim( preg_replace( '/\s+/u', ' ', (string) $value ) ); } /** * Everything the admin screen needs to describe the lock, in one array. * * No `writable` and no `has_code` any more: nothing in the plugin writes this file (1.10.0), so * both would describe a power that no longer exists. * * @return array */ public static function status() { $data = self::read(); return array( 'path' => self::path(), 'exists' => ! empty( $data['exists'] ), 'marker' => ! empty( $data['marker'] ), 'active' => self::is_active(), 'warnings' => (array) $data['warnings'], ); } }