time() - MINUTE_IN_SECONDS ) { return; } $all[ $client_id ] = array( 'time' => time(), 'why' => sanitize_key( $why ), ); uasort( $all, static function ( $a, $b ) { return (int) $b['time'] - (int) $a['time']; } ); update_option( self::OPTION, array_slice( $all, 0, 20, true ), false ); } /** * The app signed in again. * * @param string $client_id Client. */ public static function clear( string $client_id ): void { $all = self::all(); if ( isset( $all[ $client_id ] ) ) { unset( $all[ $client_id ] ); update_option( self::OPTION, $all, false ); } } /** * Refusals of the last 30 days, newest first. * * @return array */ public static function all(): array { $all = get_option( self::OPTION ); if ( ! is_array( $all ) ) { return array(); } return array_filter( $all, static function ( $r ) { return is_array( $r ) && (int) ( $r['time'] ?? 0 ) > time() - 30 * DAY_IN_SECONDS; } ); } /** * Last time the app used or received a sign-in, as a timestamp. * * @param string $client_id Client. */ public static function last_good( string $client_id ): int { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT MAX(last_used_at) AS used, MAX(created_at) AS issued FROM %i WHERE client_id = %s AND kind IN ('at','rt')", Installer::table( 'tokens' ), $client_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. $used = $row && $row->used ? (int) strtotime( $row->used . ' UTC' ) : 0; $issued = $row && $row->issued ? (int) strtotime( $row->issued . ' UTC' ) : 0; return max( $used, $issued ); } /** * Last time the app made a request, as a timestamp. * * @param string $client_id Client. */ public static function last_used( string $client_id ): int { global $wpdb; $used = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(last_used_at) FROM %i WHERE client_id = %s AND kind IN ('at','rt')", Installer::table( 'tokens' ), $client_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. return $used ? (int) strtotime( $used . ' UTC' ) : 0; } /** * Apps that were refused and have not signed in since. * * @return array */ public static function problems(): array { global $wpdb; $out = array(); foreach ( self::all() as $client_id => $r ) { if ( self::last_good( (string) $client_id ) >= (int) $r['time'] + 2 ) { continue; } $name = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT client_name FROM %i WHERE client_id = %s', Installer::table( 'oauth_clients' ), (string) $client_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. $out[ (string) $client_id ] = $r + array( 'name' => '' !== $name ? $name : __( 'An AI app', 'handymcp' ) ); } return $out; } /** * Apps approved more than 30 minutes ago that never used that sign-in. * * @return array */ public static function waiting(): array { global $wpdb; $t = Installer::table( 'tokens' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT client_id, family FROM %i WHERE kind = 'rt' AND revoked = 0 AND client_id <> '' AND family <> '' AND (expires_at IS NULL OR expires_at > %s)", $t, gmdate( 'Y-m-d H:i:s' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. $out = array(); foreach ( (array) $rows as $r ) { $f = $wpdb->get_row( $wpdb->prepare( 'SELECT MIN(created_at) AS since, MAX(last_used_at) AS used FROM %i WHERE family = %s', $t, (string) $r->family ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. if ( ! $f || $f->used || ! $f->since ) { continue; } $since = (int) strtotime( $f->since . ' UTC' ); if ( time() - $since < self::WAIT || isset( $out[ (string) $r->client_id ] ) || self::last_used( (string) $r->client_id ) > $since ) { continue; } $name = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT client_name FROM %i WHERE client_id = %s', Installer::table( 'oauth_clients' ), (string) $r->client_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. $out[ (string) $r->client_id ] = array( 'time' => $since, 'name' => '' !== $name ? $name : __( 'An AI app', 'handymcp' ), ); } return $out; } /** * One sentence for an app that was approved but never connected. * * @param string $name App name. * @param int $time Approved at. */ public static function waiting_message( string $name, int $time ): string { /* translators: 1: app name, 2: time, 3: app name, 4: app name. */ return sprintf( __( '%1$s was approved %2$s ago but has not used this site since. If %3$s shows Reconnect, click it and approve again. If you no longer use %4$s here, disconnect it.', 'handymcp' ), $name, human_time_diff( $time ), $name, $name ); } /** * Why the sign-in was refused, in plain words. * * @param string $why Reason key. */ public static function reason( string $why ): string { switch ( $why ) { case 'expired': return __( 'its sign-in expired after a long break', 'handymcp' ); case 'replay': return __( 'it renewed the same sign-in twice, so the site ended that sign-in to keep it safe', 'handymcp' ); default: return __( 'its sign-in was ended (disconnected, reset or used twice)', 'handymcp' ); } } /** * One sentence for an app that needs to reconnect. * * @param string $name App name. * @param array $r Refusal. */ public static function message( string $name, array $r ): string { /* translators: 1: app name, 2: time, 3: reason, 4: app name. */ return sprintf( __( '%1$s could not sign in %2$s ago because %3$s. Open %4$s, find this site under connectors and click Reconnect.', 'handymcp' ), $name, human_time_diff( (int) $r['time'] ), self::reason( (string) $r['why'] ), $name ); } }