PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / ai-copilot / hooks.php

hooks.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.7, at includes/ai-copilot/hooks.php

307 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — AI Copilot WordPress hooks.
4 *
5 * Intercepts post saves, term creates/updates, and comment inserts/edits
6 * then schedules async WP-Cron jobs to run the OpenAI analysis outside
7 * the current HTTP request. The editor stays responsive even when the
8 * OpenAI API is slow.
9 *
10 * Deduplication: a 60-second transient (`desktop_mode_ai_q_{type}_{id}`) prevents
11 * the same entity from being queued twice when WordPress fires the hook
12 * multiple times in one request (e.g. `save_post` fires for the revision
13 * AND the parent on autosave-adjacent events).
14 *
15 * @package WPDesktopMode
16 */
17
18 defined( 'ABSPATH' ) || exit;
19
20 // ---------------------------------------------------------------------------
21 // Helpers
22 // ---------------------------------------------------------------------------
23
24 /**
25 * Schedules an AI analysis job and ensures it runs even in environments
26 * where WP-Cron's HTTP-based spawn_cron() cannot reach the site
27 * (e.g. Docker dev setups where localhost:PORT doesn't resolve from
28 * inside the container).
29 *
30 * Two-track approach:
31 * 1. WP-Cron: reliable in production with a system cron or a host
32 * that can make loopback HTTP requests.
33 * 2. Shutdown handler: runs the job in the same PHP process, after
34 * the HTTP response has been sent to the browser via
35 * fastcgi_finish_request() (available in PHP-FPM, which Docker
36 * environments use). Falls back to running after the request in
37 * non-FPM setups (e.g. WP-CLI).
38 *
39 * The deduplication transient prevents the same entity from being
40 * queued and run twice within the guard window.
41 *
42 * @since 0.14.0
43 *
44 * @param string $hook Cron hook name, e.g. 'desktop_mode_ai_analyze_post'.
45 * @param array $args Arguments passed to the hook callback.
46 * @param string $dedup_key Unique string used to build the transient key.
47 */
48 function desktop_mode_ai_schedule_job( $hook, array $args, $dedup_key ) {
49 $transient = 'desktop_mode_ai_q_' . md5( $dedup_key );
50
51 if ( get_transient( $transient ) ) {
52 return; // Already queued within the guard window — skip.
53 }
54
55 // Schedule via WP-Cron for production environments.
56 wp_schedule_single_event( time(), $hook, $args );
57
58 // Mark as queued before the shutdown handler fires so re-entrant
59 // saves (e.g. a meta update during analysis) don't double-queue.
60 set_transient( $transient, 1, 120 );
61
62 // Run on shutdown — covers Docker dev environments and WP-CLI where
63 // WP-Cron's loopback HTTP request cannot reach the site.
64 // PHP_INT_MAX priority ensures we run last, after WordPress has
65 // finished any pending DB writes from the current request.
66 add_action(
67 'shutdown',
68 static function () use ( $hook, $args ) {
69 // Send the HTTP response to the browser before the
70 // (potentially slow) OpenAI call so the editor stays
71 // responsive. fastcgi_finish_request() is a PHP-FPM
72 // function; in other SAPIs (CLI, Apache mod_php) it is
73 // not available and we proceed without it — the analysis
74 // still runs, it just blocks the request exit briefly.
75 //
76 // The OpenAI HTTP call itself bumps `set_time_limit()`
77 // when (and only when) it is about to fire — see
78 // `desktop_mode_ai_do_request()` in `openai.php`.
79 // Bumping it here would widen the scope to every
80 // scheduled job whether or not it ends up hitting the
81 // remote API, which the WordPress.org plugin review
82 // guidelines discourage.
83 if ( function_exists( 'fastcgi_finish_request' ) ) {
84 fastcgi_finish_request();
85 }
86
87 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- generic dispatcher; caller passes a desktop_mode_* hook name.
88 do_action_ref_array( $hook, $args );
89 },
90 PHP_INT_MAX
91 );
92 }
93
94 /**
95 * Returns the user ID to attribute the API call to, trying three sources
96 * in priority order:
97 *
98 * 1. The currently logged-in user (HTTP request context).
99 * 2. A provided fallback ID (e.g. post author).
100 * 3. The first administrator who has AI enabled — covers anonymous
101 * comments, WP-CLI imports, and REST API requests without an
102 * authenticated user context.
103 *
104 * @since 0.14.0
105 *
106 * @param int $fallback_user_id Author/owner to try when no current user.
107 * @return int User ID, or 0 if no AI-enabled user could be found.
108 */
109 function desktop_mode_ai_resolve_user_id( $fallback_user_id = 0 ) {
110 $uid = get_current_user_id();
111 if ( $uid > 0 ) {
112 return $uid;
113 }
114
115 $fallback = (int) $fallback_user_id;
116 if ( $fallback > 0 ) {
117 return $fallback;
118 }
119
120 // Last resort: any administrator with AI configured. Scans the first
121 // 20 admins to avoid a full table scan on large sites.
122 return desktop_mode_ai_find_enabled_user();
123 }
124
125 /**
126 * Returns the first administrator user ID that has AI features enabled.
127 *
128 * Used as a last-resort fallback for anonymous comments, WP-CLI imports,
129 * and other contexts where no user session is available.
130 *
131 * @since 0.14.0
132 *
133 * @return int User ID, or 0 if none found.
134 */
135 function desktop_mode_ai_find_enabled_user() {
136 $admin_ids = get_users(
137 array(
138 'role' => 'administrator',
139 'number' => 20,
140 'fields' => 'ID',
141 )
142 );
143
144 foreach ( $admin_ids as $uid ) {
145 if ( desktop_mode_ai_is_enabled( (int) $uid ) ) {
146 return (int) $uid;
147 }
148 }
149
150 return 0;
151 }
152
153 // ---------------------------------------------------------------------------
154 // Posts & pages
155 // ---------------------------------------------------------------------------
156
157 /**
158 * Fires after a post is saved (both create and update).
159 *
160 * Excluded:
161 * - Auto-saves (DOING_AUTOSAVE constant)
162 * - Revisions (post_type = 'revision')
163 * - Auto-draft / trash / inherit statuses
164 * - Unsupported post types (filtered by `desktop_mode_ai_supported_post_types`)
165 *
166 * We use the DOING_AUTOSAVE constant directly rather than wp_doing_autosave()
167 * and check post_type directly rather than calling wp_is_post_revision() —
168 * both wrapper functions may be unavailable in test/CLI bootstrap contexts
169 * where the hook still fires, while the underlying constants/properties are
170 * always present.
171 *
172 * @since 0.14.0
173 *
174 * @param int $post_id Post ID.
175 * @param WP_Post $post Post object.
176 * @param bool $update Whether this is an update.
177 */
178 function desktop_mode_ai_on_save_post( $post_id, WP_Post $post, $update ) {
179 // Skip autosaves — constant is reliable in all contexts.
180 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
181 return;
182 }
183 // Skip revisions — post_type is always populated on the $post object.
184 if ( 'revision' === $post->post_type ) {
185 return;
186 }
187 if ( in_array( $post->post_status, array( 'auto-draft', 'trash', 'inherit' ), true ) ) {
188 return;
189 }
190
191 $supported_types = (array) apply_filters(
192 'desktop_mode_ai_supported_post_types',
193 array( 'post', 'page' )
194 );
195 if ( ! in_array( $post->post_type, $supported_types, true ) ) {
196 return;
197 }
198
199 $user_id = desktop_mode_ai_resolve_user_id( (int) $post->post_author );
200 if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
201 return;
202 }
203
204 desktop_mode_ai_schedule_job(
205 'desktop_mode_ai_analyze_post',
206 array( $post_id, $user_id ),
207 'post_' . $post_id
208 );
209 }
210 add_action( 'save_post', 'desktop_mode_ai_on_save_post', 20, 3 );
211
212 // ---------------------------------------------------------------------------
213 // Taxonomy terms — categories & tags (and any registered taxonomy)
214 // ---------------------------------------------------------------------------
215
216 /**
217 * Shared handler for term creation and edit.
218 *
219 * @since 0.14.0
220 *
221 * @param int $term_id Term ID.
222 * @param int $tt_id Term taxonomy ID (unused).
223 * @param string $taxonomy Taxonomy slug.
224 */
225 function desktop_mode_ai_on_term_change( $term_id, $tt_id, $taxonomy ) {
226 $supported_taxonomies = (array) apply_filters(
227 'desktop_mode_ai_supported_taxonomies',
228 array( 'category', 'post_tag' )
229 );
230 if ( ! in_array( $taxonomy, $supported_taxonomies, true ) ) {
231 return;
232 }
233
234 $user_id = desktop_mode_ai_resolve_user_id();
235 if ( $user_id <= 0 ) {
236 // No identifiable user for this term change — skip.
237 // Term creates/edits in WP-CLI or batch imports will simply not
238 // get analyzed unless a specific user context is available.
239 return;
240 }
241 if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
242 return;
243 }
244
245 desktop_mode_ai_schedule_job(
246 'desktop_mode_ai_analyze_term',
247 array( $term_id, $taxonomy, $user_id ),
248 'term_' . $term_id . '_' . $taxonomy
249 );
250 }
251
252 // `created_term` fires on new term insertion (all taxonomies).
253 add_action( 'created_term', 'desktop_mode_ai_on_term_change', 20, 3 );
254
255 // `edited_term` fires after a term has been updated.
256 add_action( 'edited_term', 'desktop_mode_ai_on_term_change', 20, 3 );
257
258 // ---------------------------------------------------------------------------
259 // Comments
260 // ---------------------------------------------------------------------------
261
262 /**
263 * Shared handler for new and edited comments.
264 *
265 * @since 0.14.0
266 *
267 * @param int $comment_id The comment ID.
268 */
269 function desktop_mode_ai_on_comment_change( $comment_id ) {
270 $comment = get_comment( $comment_id );
271 if ( ! $comment instanceof WP_Comment ) {
272 return;
273 }
274
275 // Skip pingbacks and trackbacks — only analyze real human comments.
276 if ( '' !== $comment->comment_type && 'comment' !== $comment->comment_type ) {
277 return;
278 }
279
280 // Resolve the user: comment author user_id if logged-in, otherwise
281 // fall back to any admin who has AI configured. We use the comment's
282 // own user_id first since the commenter may have AI enabled; then
283 // fall back to current_user (moderator context), then to 0 (rejected).
284 $user_id = (int) $comment->user_id;
285 if ( $user_id <= 0 ) {
286 $user_id = desktop_mode_ai_resolve_user_id();
287 }
288 if ( $user_id <= 0 ) {
289 return;
290 }
291 if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
292 return;
293 }
294
295 desktop_mode_ai_schedule_job(
296 'desktop_mode_ai_analyze_comment',
297 array( $comment_id, $user_id ),
298 'comment_' . $comment_id
299 );
300 }
301
302 // `wp_insert_comment` fires after a new comment is inserted into the DB.
303 add_action( 'wp_insert_comment', 'desktop_mode_ai_on_comment_change', 20, 1 );
304
305 // `edit_comment` fires after an existing comment is updated.
306 add_action( 'edit_comment', 'desktop_mode_ai_on_comment_change', 20, 1 );
307