'handymcp_share_errors', 'share' => $on ? '1' : '0', 'back' => rawurlencode( $back ) ), admin_url( 'admin-post.php' ) ), 'handymcp_share_errors' );
return '' . esc_html( $label ) . ' ';
}
/** What a report contains, in plain words. */
public static function what_is_sent(): string {
return __( 'Once a day: which AI tools failed, the error messages with addresses, emails, keys and numbers removed, which AI app was used, connection check results, your WordPress, PHP and HandyMCP versions, your theme and active plugin names and versions, whether a CDN or firewall is in front of the site, and a one-way code for your site. Never your site address, content, users, customers or keys.', 'handymcp' );
}
/**
* One-time question inside the plugin screens, shown until answered.
*/
public static function banner(): void {
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connect'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which screen is open.
if ( ! in_array( $tab, array( 'connect', 'settings' ), true ) ) {
return;
}
if ( self::asked() || ! current_user_can( 'manage_options' ) ) {
return;
}
echo wp_kses( '
' . esc_html__( 'Help improve HandyMCP?', 'handymcp' ) . ' ' . esc_html__( 'Share anonymous error reports so we can fix problems faster.', 'handymcp' ) . ' ? ' . self::button( true, __( 'Yes, share', 'handymcp' ), 'button-primary' ) . self::button( false, __( 'No thanks', 'handymcp' ), '' ) . '
', \HandyMCP\Support\Html::allowed() );
}
/**
* Row for the Settings screen.
*/
public static function settings_row(): void {
$back = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : admin_url();
$url = static function ( bool $on ) use ( $back ): string {
return wp_nonce_url( add_query_arg( array( 'action' => 'handymcp_share_errors', 'share' => $on ? '1' : '0', 'back' => rawurlencode( $back ) ), admin_url( 'admin-post.php' ) ), 'handymcp_share_errors' );
};
$st = get_option( self::STATUS );
$status = '';
if ( self::enabled() && is_array( $st ) ) {
$items = (int) ( $st['items'] ?? 0 );
$status = ! empty( $st['ok'] )
/* translators: 1: date and time, 2: number of items. */
? sprintf( _n( 'Last report sent %1$s with %2$d item.', 'Last report sent %1$s with %2$d items.', $items, 'handymcp' ), wp_date( 'M j, H:i', (int) $st['time'] ), $items )
/* translators: 1: date and time, 2: reason. */
: sprintf( __( 'Last report could not be sent (%1$s): %2$s.', 'handymcp' ), wp_date( 'M j, H:i', (int) $st['time'] ), (string) ( $st['result'] ?? '' ) );
}
// Saves right away when switched (it is not part of the Settings form).
echo '' . esc_html__( 'Anonymous error reports', 'handymcp' ) . ' ? ' . ( '' !== $status ? '
' . esc_html( $status ) . '
' : '' ) . '
'
. '
';
}
private static function max_id(): int {
global $wpdb;
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(id) FROM %i', Installer::table( 'log' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current.
}
/**
* Remove anything that could identify a site or person from an error message.
*/
public static function clean( string $msg, bool $keep_numbers = false ): string {
$msg = wp_strip_all_tags( $msg );
$msg = preg_replace( '#https?://\S+#i', '[url]', $msg );
// This site's own host name, without a scheme (for example in "Could not resolve host").
foreach ( array_unique( array_filter( array( (string) wp_parse_url( home_url(), PHP_URL_HOST ), (string) wp_parse_url( site_url(), PHP_URL_HOST ) ) ) ) as $host ) {
$msg = str_ireplace( array( $host, preg_replace( '/^www\./i', '', $host ) ), '[site]', (string) $msg );
}
// IP addresses, also when numbers are kept.
$msg = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[ip]', (string) $msg );
$msg = preg_replace( '/\b(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}\b|\b(?:[0-9a-f]{1,4}:)+:(?:[0-9a-f]{1,4}:?)*[0-9a-f]{1,4}\b/i', '[ip]', (string) $msg );
// Long random-looking strings (tokens, keys, nonces).
$msg = preg_replace( '/\b(?=[A-Za-z_\-]*[0-9])(?=[0-9_\-]*[A-Za-z])[A-Za-z0-9_\-]{24,}\b/', '[key]', (string) $msg );
$msg = preg_replace( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[email]', $msg );
$msg = preg_replace( '/(["\'“‘]).{1,200}?(["\'”’])/u', '"…"', $msg );
$msg = preg_replace( '#[A-Za-z]:\\\\[^\s"\']+#', '[path]', $msg );
$msg = preg_replace( '#(/[\w.-]+){2,}#', '[path]', $msg );
$msg = preg_replace( '#(?get_results( $wpdb->prepare( "SELECT tool, tool_group, client, summary FROM %i WHERE id > %d AND id <= %d AND status = 'error' ORDER BY id ASC LIMIT 2000", Installer::table( 'log' ), $last, $max ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current.
update_option( self::LAST_ID, $max, false );
update_option( self::SENT, time(), false );
$items = array();
foreach ( $rows as $r ) {
// License messages are not bugs.
if ( false !== stripos( (string) $r->summary, 'not activated on this site' ) ) {
continue;
}
$client = strtolower( (string) $r->client );
foreach ( array( 'claude', 'chatgpt', 'openai', 'cursor', 'vscode', 'windsurf', 'gemini', 'copilot' ) as $known ) {
if ( false !== strpos( $client, $known ) ) {
$client = $known;
break;
}
}
$client = preg_match( '/^[a-z]+$/', $client ) ? $client : 'other';
$msg = self::clean( (string) $r->summary );
$k = md5( $r->tool . '|' . $msg . '|' . $client );
if ( ! isset( $items[ $k ] ) ) {
$items[ $k ] = array( 'tool' => (string) $r->tool, 'group' => (string) $r->tool_group, 'client' => $client, 'message' => $msg, 'count' => 0 );
}
++$items[ $k ]['count'];
}
// Connection problems found by the last connection check.
$diag = get_option( Diagnostics::RESULT );
$conn = is_array( $diag ) && (int) ( $diag['when'] ?? 0 ) > time() - 2 * DAY_IN_SECONDS ? self::connection_items( $diag ) : array();
if ( ! $items && ! $conn ) {
return;
}
$items = array_merge( array_values( $items ), $conn );
self::post( $items );
}
/**
* Connection check problems as report items. Numbers are kept (HTTP codes, Cloudflare error codes).
*
* @param array $diag Connection check result.
* @return array>
*/
private static function connection_items( array $diag ): array {
$out = array();
foreach ( (array) ( $diag['checks'] ?? array() ) as $c ) {
if ( in_array( $c['status'] ?? '', array( 'fail', 'warn' ), true ) && ! in_array( $c['id'] ?? '', array( 'cron', 'object_cache', 'signin' ), true ) ) {
$out[] = array( 'tool' => 'connection_check', 'group' => 'connection', 'client' => 'site', 'message' => self::clean( strtoupper( (string) $c['status'] ) . ' ' . (string) $c['id'] . ': ' . (string) $c['detail'], true ), 'count' => 1 );
}
}
return $out;
}
/**
* Send a connection report soon after a check finds a problem, at most every 6 hours for the same problems.
*
* @param array $diag Connection check result.
*/
public static function send_connection( array $diag ): void {
if ( ! self::enabled() ) {
return;
}
$items = self::connection_items( $diag );
$fails = array_filter( $items, static function ( $i ) { return 0 === strpos( (string) $i['message'], 'FAIL' ); } );
if ( ! $fails ) {
return;
}
$key = 'handymcp_conn_report_' . md5( implode( '|', wp_list_pluck( $fails, 'message' ) ) );
if ( get_transient( $key ) ) {
return;
}
set_transient( $key, 1, 6 * HOUR_IN_SECONDS );
self::post( $items );
}
/**
* Post report items with the site's versions, plugins and site details.
*
* @param array> $items Items.
*/
private static function post( array $items ): void {
// Test and local sites are not real installs: their errors would only add noise.
if ( \HandyMCP\Support\Env::is_local() ) {
return;
}
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = array();
$all = get_plugins();
foreach ( (array) get_option( 'active_plugins', array() ) as $file ) {
if ( isset( $all[ $file ] ) ) {
$plugins[ dirname( (string) $file ) ] = (string) $all[ $file ]['Version'];
}
}
$theme = wp_get_theme();
$res = wp_remote_post(
self::ENDPOINT,
array(
'timeout' => 8,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode(
array(
'product' => 'handymcp',
'version' => HANDYMCP_VERSION,
'site' => hash( 'sha256', home_url() . wp_salt( 'auth' ) ), // Same value every day, cannot be turned back into the address.
'wp' => get_bloginfo( 'version' ),
'php' => PHP_VERSION,
'theme' => $theme->get_template(),
'plugins' => array_slice( $plugins, 0, 80, true ),
'items' => array_slice( array_values( $items ), 0, 100 ),
'cdn' => Blockers::is_cloudflare() ? 'cloudflare' : '',
'waf' => Blockers::prepend_waf(),
)
),
)
);
$code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
update_option( self::STATUS, array( 'time' => time(), 'ok' => $code >= 200 && $code < 300, 'result' => is_wp_error( $res ) ? $res->get_error_message() : 'HTTP ' . $code, 'items' => count( $items ) ), false );
}
}