'', 'enabled' => 1, 'identify_users' => 0, 'position' => '', 'launcher_size' => '', 'offset_x' => '', 'offset_y' => '', 'hide_launcher' => 0, 'page_context' => 0, 'auto_open_delay' => '', 'language' => self::LANGUAGE_DEFAULT, 'excluded_post_ids' => '', ); } /** * Reduce a submitted (or stored) language value to something the widget * accepts: one of {@see LANGUAGE_CODES}, or {@see LANGUAGE_DEFAULT}. * * Case and region subtags are folded the way the widget folds them * (`AR`, `ar-EG`, `pt_BR` → `ar`, `ar`, `pt`); blank, `default` and * anything unknown collapse to the default so a stale or hostile value * can never reach the page as `data-language`. * * @param mixed $value Raw value. * @return string */ public static function sanitize_language( $value ) { if ( ! is_scalar( $value ) ) { return self::LANGUAGE_DEFAULT; } $code = strtolower( trim( (string) $value ) ); if ( '' === $code || self::LANGUAGE_DEFAULT === $code ) { return self::LANGUAGE_DEFAULT; } $primary = preg_split( '/[-_]/', $code, 2 ); $primary = is_array( $primary ) && isset( $primary[0] ) ? $primary[0] : ''; return in_array( $primary, self::LANGUAGE_CODES, true ) ? $primary : self::LANGUAGE_DEFAULT; } /** * Stored display settings, merged over the defaults. * * @return array */ public static function settings() { $stored = get_option( self::OPTION_SETTINGS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return array_merge( self::default_settings(), $stored ); } /** * Read one display setting. * * @param string $key Setting key. * @param mixed $fallback Value when the key is absent. * @return mixed */ public static function setting( $key, $fallback = '' ) { $settings = self::settings(); return isset( $settings[ $key ] ) ? $settings[ $key ] : $fallback; } /** * Persist display settings. * * @param array $settings Sanitized settings. * @return void */ public static function save_settings( array $settings ) { update_option( self::OPTION_SETTINGS, $settings, false ); } /** * The merchant's Agentency personal access token, or an empty string — * also when the stored copy is sealed and can no longer be opened. * * @return string */ public static function api_key() { $stored = self::stored_api_key(); if ( 0 !== strpos( $stored, self::API_KEY_SEALED_PREFIX ) ) { return $stored; } $opened = self::open_api_key( substr( $stored, strlen( self::API_KEY_SEALED_PREFIX ) ) ); return null === $opened ? '' : $opened; } /** * Store the access token with autoload disabled — it is only ever needed * on the settings screen and during a heartbeat, never on a front-end * page load. * * Sealed with libsodium's secretbox (core ships `sodium_compat` where the * extension is missing) under a key derived from the site's * `AUTH_KEY` / `AUTH_SALT` (`wp_salt( 'auth' )`), so a database dump or * backup alone does not carry a usable credential. It is not a vault: * anyone who can read wp-config.php as well can open it, and changing * those salts makes the stored token unreadable (the settings screen and * Site Health then ask for a reconnect). Without sodium the token is * stored as given and Site Health says so. * * @param string $key Personal access token. * @return void */ public static function save_api_key( $key ) { update_option( self::OPTION_API_KEY, self::seal_api_key( (string) $key ), false ); } /** * How the stored token is kept: one of the `API_KEY_STORAGE_*` values. * * @return string */ public static function api_key_storage() { $stored = self::stored_api_key(); if ( '' === $stored ) { return self::API_KEY_STORAGE_NONE; } if ( 0 !== strpos( $stored, self::API_KEY_SEALED_PREFIX ) ) { return self::API_KEY_STORAGE_PLAINTEXT; } return '' === self::api_key() ? self::API_KEY_STORAGE_UNREADABLE : self::API_KEY_STORAGE_ENCRYPTED; } /** * Re-store a plaintext token (saved before 1.4.0) sealed, when this site * can seal it. Run from the settings screen and the heartbeat, the two * places that read the token anyway. * * @return void */ public static function encrypt_stored_api_key() { if ( self::API_KEY_STORAGE_PLAINTEXT === self::api_key_storage() && self::can_seal() ) { self::save_api_key( self::stored_api_key() ); } } /** * The raw option value. * * @return string */ private static function stored_api_key() { $stored = get_option( self::OPTION_API_KEY, '' ); return is_string( $stored ) ? $stored : ''; } /** * Can this site seal the token? * * @return bool */ private static function can_seal() { return function_exists( 'sodium_crypto_secretbox' ) && function_exists( 'sodium_crypto_secretbox_open' ) && defined( 'SODIUM_CRYPTO_SECRETBOX_NONCEBYTES' ) && defined( 'SODIUM_CRYPTO_SECRETBOX_MACBYTES' ) && function_exists( 'wp_salt' ); } /** * The 32-byte secretbox key for this site. * * @return string Binary key. */ private static function sealing_key() { return hash_hmac( 'sha256', 'agentency-chatbot-api-key', wp_salt( 'auth' ), true ); } /** * Seal a token, or return it unchanged when it is empty or sealing is * unavailable. * * Hex rather than base64 on purpose: the WordPress coding standard flags * `base64_encode()` as an obfuscation pattern. * * @param string $plain Token. * @return string */ private static function seal_api_key( $plain ) { if ( '' === $plain || ! self::can_seal() ) { return $plain; } try { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); } catch ( Exception $e ) { return $plain; } return self::API_KEY_SEALED_PREFIX . bin2hex( $nonce . sodium_crypto_secretbox( $plain, $nonce, self::sealing_key() ) ); } /** * Open a sealed token. * * @param string $hex Hex payload after the prefix. * @return string|null The token, or null when it cannot be opened. */ private static function open_api_key( $hex ) { if ( ! self::can_seal() || ! preg_match( '/^(?:[0-9a-f]{2})+$/', $hex ) ) { return null; } $raw = (string) hex2bin( $hex ); if ( strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES ) { return null; } try { $plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), self::sealing_key() ); } catch ( Exception $e ) { return null; } return is_string( $plain ) && '' !== $plain ? $plain : null; } /** * A non-reversible hint for the settings screen ("…a1b2"), so the merchant * can tell which token is stored without the page ever rendering it. * * @return string */ public static function api_key_hint() { $key = self::api_key(); if ( '' === $key ) { return ''; } return str_repeat( '•', 8 ) . substr( $key, -4 ); } /** * The active connection: which chatbot, which token, which install row. * * @return array */ public static function connection() { $stored = get_option( self::OPTION_CONNECTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return array_merge( array( 'chatbot_id' => 0, 'chatbot_name' => '', 'widget_token' => '', 'widget_url' => '', 'api_base_url' => '', 'installation_id' => 0, 'site_host' => '', 'connected_at' => 0, 'last_heartbeat' => 0, 'last_heartbeat_failed_at' => 0, 'heartbeat_interval_hours' => 0, 'removed_at' => 0, ), $stored ); } /** * Agentency reported this site's installation as removed (someone * disconnected it from the Agentency dashboard): stop rendering the * widget and checking in, and remember when, so the admin screens can * offer a reconnect. * * The token is kept — it still works, and reconnecting with it is one * click — and so are the agent name, host and API origin the notice and * the reconnect need. Only the live state goes. * * @return void */ public static function mark_removed() { $connection = self::connection(); $connection['widget_token'] = ''; $connection['widget_url'] = ''; $connection['installation_id'] = 0; $connection['removed_at'] = time(); self::save_connection( $connection ); } /** * Was the connection ended from the Agentency side, and not re-made since? * * @return bool */ public static function was_removed() { $connection = self::connection(); return ! self::is_connected() && is_numeric( $connection['removed_at'] ) && (int) $connection['removed_at'] > 0; } /** * When the most recent check-in attempt failed, or 0 when the last attempt * succeeded (or none has failed yet). * * A failure older than the last success is history, not a problem, so it * reads as 0 — the settings screen only warns about a failure the site * has not recovered from. Non-numeric stored values also read as 0, so a * corrupted option can never render a bogus warning. * * @return int Unix timestamp, or 0. */ public static function unresolved_heartbeat_failure() { $connection = self::connection(); $failed_at = is_numeric( $connection['last_heartbeat_failed_at'] ) ? (int) $connection['last_heartbeat_failed_at'] : 0; $last_ok = is_numeric( $connection['last_heartbeat'] ) ? (int) $connection['last_heartbeat'] : 0; return $failed_at > $last_ok ? $failed_at : 0; } /** * Reduce a server-sent heartbeat cadence to whole hours inside * [{@see HEARTBEAT_INTERVAL_MIN_HOURS}, {@see HEARTBEAT_INTERVAL_MAX_HOURS}]. * Anything absent, non-numeric or below one hour means "not sent" and * yields 0, so a bad value never overwrites a good stored one. * * @param mixed $value Raw `heartbeat_interval_hours` from the API. * @return int Hours, or 0 when unusable. */ public static function sanitize_heartbeat_interval( $value ) { if ( ! is_numeric( $value ) ) { return 0; } $hours = (int) $value; if ( $hours < self::HEARTBEAT_INTERVAL_MIN_HOURS ) { return 0; } return min( self::HEARTBEAT_INTERVAL_MAX_HOURS, $hours ); } /** * The heartbeat cadence to schedule, in hours: the stored server value, * else {@see HEARTBEAT_INTERVAL_DEFAULT_HOURS}. * * @return int */ public static function heartbeat_interval_hours() { $connection = self::connection(); $hours = self::sanitize_heartbeat_interval( $connection['heartbeat_interval_hours'] ); return $hours > 0 ? $hours : self::HEARTBEAT_INTERVAL_DEFAULT_HOURS; } /** * Persist the active connection. * * @param array $connection Connection payload. * @return void */ public static function save_connection( array $connection ) { update_option( self::OPTION_CONNECTION, $connection, true ); } /** * Is a chatbot connected and ready to render? * * @return bool */ public static function is_connected() { $connection = self::connection(); return '' !== $connection['widget_token'] && '' !== $connection['widget_url']; } /** * Forget the connection AND the credential. Used by Disconnect and by * `uninstall.php`. * * @return void */ public static function forget() { delete_option( self::OPTION_API_KEY ); delete_option( self::OPTION_CONNECTION ); } }