# desktop-mode/0.9.1/includes/ai-copilot/hooks.php

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 0.9.1. 205 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/0.9.1/code/includes/ai-copilot/hooks.php
- Raw: https://pluginprobe.com/plugins/desktop-mode/0.9.1/raw/includes/ai-copilot/hooks.php
- Modified: 2026-06-07T20:55:46+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/0.9.1/code/includes/ai-copilot/hooks.php#L10-L20`.

```php
<?php
/**
 * Desktop Mode — AI Copilot WordPress hooks.
 *
 * Intercepts comment inserts/edits, then schedules an async WP-Cron job
 * to run the OpenAI spam/harmful analysis outside the current HTTP
 * request. Moderation stays responsive even when the OpenAI API is slow.
 *
 * Comment analysis is the only auto-analysis the copilot performs — it
 * feeds the comments-window spam score. Posts, pages, and taxonomy terms
 * are NOT analyzed; the AI assistant finds them with native WordPress
 * keyword search instead (see search.php).
 *
 * Deduplication: a 60-second transient (`desktop_mode_ai_q_{type}_{id}`) prevents
 * the same comment from being queued twice when WordPress fires the hook
 * multiple times in one request.
 *
 * @package WPDesktopMode
 */

defined( 'ABSPATH' ) || exit;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Schedules an AI analysis job and ensures it runs even in environments
 * where WP-Cron's HTTP-based spawn_cron() cannot reach the site
 * (e.g. Docker dev setups where localhost:PORT doesn't resolve from
 * inside the container).
 *
 * Two-track approach:
 *   1. WP-Cron: reliable in production with a system cron or a host
 *      that can make loopback HTTP requests.
 *   2. Shutdown handler: runs the job in the same PHP process, after
 *      the HTTP response has been sent to the browser via
 *      fastcgi_finish_request() (available in PHP-FPM, which Docker
 *      environments use). Falls back to running after the request in
 *      non-FPM setups (e.g. WP-CLI).
 *
 * The deduplication transient prevents the same entity from being
 * queued and run twice within the guard window.
 *
 * @since 0.14.0
 *
 * @param string $hook      Cron hook name, e.g. 'desktop_mode_ai_analyze_post'.
 * @param array  $args      Arguments passed to the hook callback.
 * @param string $dedup_key Unique string used to build the transient key.
 */
function desktop_mode_ai_schedule_job( $hook, array $args, $dedup_key ) {
	$transient = 'desktop_mode_ai_q_' . md5( $dedup_key );

	if ( get_transient( $transient ) ) {
		return; // Already queued within the guard window — skip.
	}

	// Schedule via WP-Cron for production environments.
	wp_schedule_single_event( time(), $hook, $args );

	// Mark as queued before the shutdown handler fires so re-entrant
	// saves (e.g. a meta update during analysis) don't double-queue.
	set_transient( $transient, 1, 120 );

	// Run on shutdown — covers Docker dev environments and WP-CLI where
	// WP-Cron's loopback HTTP request cannot reach the site.
	// PHP_INT_MAX priority ensures we run last, after WordPress has
	// finished any pending DB writes from the current request.
	add_action(
		'shutdown',
		static function () use ( $hook, $args ) {
			// Send the HTTP response to the browser before the
			// (potentially slow) OpenAI call so the editor stays
			// responsive. fastcgi_finish_request() is a PHP-FPM
			// function; in other SAPIs (CLI, Apache mod_php) it is
			// not available and we proceed without it — the analysis
			// still runs, it just blocks the request exit briefly.
			//
			// The OpenAI HTTP call itself bumps `set_time_limit()`
			// when (and only when) it is about to fire — see
			// `desktop_mode_ai_do_request()` in `openai.php`.
			// Bumping it here would widen the scope to every
			// scheduled job whether or not it ends up hitting the
			// remote API, which the WordPress.org plugin review
			// guidelines discourage.
			if ( function_exists( 'fastcgi_finish_request' ) ) {
				fastcgi_finish_request();
			}

			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- generic dispatcher; caller passes a desktop_mode_* hook name.
			do_action_ref_array( $hook, $args );
		},
		PHP_INT_MAX
	);
}

/**
 * Returns the user ID to attribute the API call to, trying three sources
 * in priority order:
 *
 *   1. The currently logged-in user (HTTP request context).
 *   2. A provided fallback ID (e.g. post author).
 *   3. The first administrator who has AI enabled — covers anonymous
 *      comments, WP-CLI imports, and REST API requests without an
 *      authenticated user context.
 *
 * @since 0.14.0
 *
 * @param int $fallback_user_id Author/owner to try when no current user.
 * @return int User ID, or 0 if no AI-enabled user could be found.
 */
function desktop_mode_ai_resolve_user_id( $fallback_user_id = 0 ) {
	$uid = get_current_user_id();
	if ( $uid > 0 ) {
		return $uid;
	}

	$fallback = (int) $fallback_user_id;
	if ( $fallback > 0 ) {
		return $fallback;
	}

	// Last resort: any administrator with AI configured. Scans the first
	// 20 admins to avoid a full table scan on large sites.
	return desktop_mode_ai_find_enabled_user();
}

/**
 * Returns the first administrator user ID that has AI features enabled.
 *
 * Used as a last-resort fallback for anonymous comments, WP-CLI imports,
 * and other contexts where no user session is available.
 *
 * @since 0.14.0
 *
 * @return int User ID, or 0 if none found.
 */
function desktop_mode_ai_find_enabled_user() {
	$admin_ids = get_users(
		array(
			'role'   => 'administrator',
			'number' => 20,
			'fields' => 'ID',
		)
	);

	foreach ( $admin_ids as $uid ) {
		if ( desktop_mode_ai_is_enabled( (int) $uid ) ) {
			return (int) $uid;
		}
	}

	return 0;
}

// ---------------------------------------------------------------------------
// Comments
// ---------------------------------------------------------------------------

/**
 * Shared handler for new and edited comments.
 *
 * @since 0.14.0
 *
 * @param int $comment_id The comment ID.
 */
function desktop_mode_ai_on_comment_change( $comment_id ) {
	$comment = get_comment( $comment_id );
	if ( ! $comment instanceof WP_Comment ) {
		return;
	}

	// Skip pingbacks and trackbacks — only analyze real human comments.
	if ( '' !== $comment->comment_type && 'comment' !== $comment->comment_type ) {
		return;
	}

	// Resolve the user: comment author user_id if logged-in, otherwise
	// fall back to any admin who has AI configured. We use the comment's
	// own user_id first since the commenter may have AI enabled; then
	// fall back to current_user (moderator context), then to 0 (rejected).
	$user_id = (int) $comment->user_id;
	if ( $user_id <= 0 ) {
		$user_id = desktop_mode_ai_resolve_user_id();
	}
	if ( $user_id <= 0 ) {
		return;
	}
	if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
		return;
	}

	desktop_mode_ai_schedule_job(
		'desktop_mode_ai_analyze_comment',
		array( $comment_id, $user_id ),
		'comment_' . $comment_id
	);
}

// `wp_insert_comment` fires after a new comment is inserted into the DB.
add_action( 'wp_insert_comment', 'desktop_mode_ai_on_comment_change', 20, 1 );

// `edit_comment` fires after an existing comment is updated.
add_action( 'edit_comment', 'desktop_mode_ai_on_comment_change', 20, 1 );

```
