PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 / comments-window / ai-moderation.php

ai-moderation.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/comments-window/ai-moderation.php

267 lines 9.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 — Native Comments Window: AI moderation.
4 *
5 * Lives on its own toggle ("Use AI to score new comments", surfaced
6 * in OS Settings → Features for users with `manage_options`). Off by
7 * default; when on, every new comment posted to the site is queued
8 * for AI analysis through the AI Copilot's existing
9 * `desktop_mode_ai_analyze_comment` job. The structured verdict
10 * (`{ harmful, spam, topic, ai_summary }`) lands in comment meta via
11 * `desktop_mode_ai_save_meta()`; the Comments window reads it back
12 * through the `desktop_mode_comments_window_spam_score` filter to
13 * bump the per-row chip, and surfaces the prose summary in a new
14 * REST field so the bundle can render it on hover.
15 *
16 * The Comments window NEVER runs the AI itself — it routes everything
17 * through the AI Copilot pipeline, which routes generation through the
18 * WordPress AI Client — so a site's Settings → Connectors config is the
19 * single source of truth for provider credentials and selection.
20 *
21 * SECURITY POSTURE
22 * ================
23 *
24 * - The option is `manage_options`-gated for read AND write — the
25 * REST permission callback rejects anyone without the cap, and
26 * the OS Settings UI hides the toggle for non-admins.
27 * - The hook on `wp_insert_comment` is a no-op when:
28 * a) the option is off,
29 * b) no admin on the site has AI configured, OR
30 * c) the comment is a pingback / trackback (only `comment_type
31 * === 'comment'` is analyzed).
32 * This makes the toggle safe to enable on sites that don't have
33 * an AI provider — it just stays inert.
34 *
35 * @package WPDesktopMode
36 */
37
38 defined( 'ABSPATH' ) || exit;
39
40 /** Site option storing the on/off state. */
41 const DESKTOP_MODE_COMMENTS_AI_OPTION = 'desktop_mode_comments_ai_moderation';
42
43 /**
44 * Returns whether AI moderation for new comments is currently enabled.
45 *
46 * @return bool
47 */
48 function desktop_mode_comments_ai_is_enabled() {
49 $raw = get_option( DESKTOP_MODE_COMMENTS_AI_OPTION, false );
50 /**
51 * Filter whether AI moderation is enabled for new comments.
52 *
53 * Site-wide; not per-user. Hooks here override the
54 * `desktop_mode_comments_ai_moderation` site option — useful for
55 * gating by environment (staging vs. production) or by feature
56 * flag.
57 *
58 * @param bool $enabled Current option value.
59 */
60 return (bool) apply_filters(
61 'desktop_mode_comments_ai_is_enabled',
62 (bool) $raw
63 );
64 }
65
66 /**
67 * On every new or edited comment, queue an AI analysis job — but only when
68 * the site has the Comments AI toggle on AND a text-generation provider is
69 * configured in Settings → Connectors. Idempotent: the AI job pipeline
70 * dedupes on `comment_<id>` while a job is pending, so overlapping fires
71 * (e.g. `wp_insert_comment` + a quick `edit_comment`) don't double-spend
72 * tokens, while an edit after a prior verdict re-analyzes to keep the
73 * spam-confidence meta fresh.
74 *
75 * This is the sole scheduler for comment analysis: the assistant being
76 * enabled no longer triggers analysis on its own (comment scoring is an
77 * opt-in Comments-window feature, off by default).
78 *
79 * @param int $comment_id The comment id (from `wp_insert_comment` or `edit_comment`).
80 */
81 function desktop_mode_comments_ai_on_new_comment( $comment_id ) {
82 $comment_id = (int) $comment_id;
83 if ( $comment_id <= 0 || ! desktop_mode_comments_ai_is_enabled() ) {
84 return;
85 }
86
87 // No usable provider configured in Connectors — stay inert so the toggle
88 // is safe to leave on before a provider is set up.
89 if ( ! desktop_mode_comments_ai_provider_configured() ) {
90 return;
91 }
92
93 $comment = get_comment( $comment_id );
94 if ( ! $comment instanceof WP_Comment ) {
95 return;
96 }
97 if ( '' !== $comment->comment_type && 'comment' !== $comment->comment_type ) {
98 return;
99 }
100
101 if ( ! function_exists( 'desktop_mode_ai_schedule_job' ) ) {
102 return;
103 }
104
105 // Comment scoring is a site-wide moderation feature: it is gated ONLY by
106 // the "Score new comments with AI" toggle + a configured Connector, never
107 // by any user's per-user assistant toggle. The user id below is passed
108 // through purely for attribution/observability — the analysis job reads
109 // none of it (the provider comes from Connectors), so the commenter's own
110 // id (0 for anonymous) is fine.
111 $user_id = (int) $comment->user_id;
112
113 desktop_mode_ai_schedule_job(
114 'desktop_mode_ai_analyze_comment',
115 array( $comment_id, $user_id ),
116 'comment_' . $comment_id
117 );
118 }
119 add_action( 'wp_insert_comment', 'desktop_mode_comments_ai_on_new_comment', 25, 1 );
120 // Re-analyze on edit so the spam-confidence meta stays fresh under normal
121 // moderation flows (the verdict filter always trusts the latest analysis).
122 add_action( 'edit_comment', 'desktop_mode_comments_ai_on_new_comment', 25, 1 );
123
124 /**
125 * Fold the AI verdict into the per-row spam-confidence score.
126 *
127 * If a comment has been analyzed and the verdict came back with
128 * `spam === true`, push the score firmly into the "high" tone
129 * (≥ 70). `harmful === true` adds 20 — harmful but on-topic comments
130 * deserve moderation attention even when they don't tip into spam.
131 *
132 * The `analyzed_at` field is intentionally ignored — we always trust
133 * the latest verdict the AI produced, even if it's a few days old.
134 * Re-analysis happens automatically on `edit_comment`, so the meta
135 * stays fresh under normal moderation flows.
136 *
137 * @param int $score Default heuristic score (0–100).
138 * @param WP_Comment $comment Comment object.
139 * @return int Adjusted score, clamped to 0–100.
140 */
141 function desktop_mode_comments_ai_filter_spam_score( $score, $comment ) {
142 if ( ! $comment instanceof WP_Comment ) {
143 return $score;
144 }
145 if ( ! function_exists( 'desktop_mode_ai_get_meta' ) ) {
146 return $score;
147 }
148 $meta = desktop_mode_ai_get_meta( 'comment', (int) $comment->comment_ID );
149 if ( ! is_array( $meta ) ) {
150 return $score;
151 }
152 $score = (int) $score;
153 if ( ! empty( $meta['spam'] ) ) {
154 // Pin to high-tone territory. Heuristics may already have it
155 // at 80; we just guarantee a floor.
156 $score = max( $score, 75 );
157 }
158 if ( ! empty( $meta['harmful'] ) ) {
159 $score = min( 100, $score + 20 );
160 }
161 return $score;
162 }
163 add_filter(
164 'desktop_mode_comments_window_spam_score',
165 'desktop_mode_comments_ai_filter_spam_score',
166 10,
167 2
168 );
169
170 /**
171 * REST: GET / POST `desktop-mode/v1/comments/ai-settings`.
172 *
173 * Read returns `{ enabled: bool, providerConfigured: bool }`.
174 * Write accepts `{ enabled: bool }`. Both `manage_options`-gated.
175 *
176 * `providerConfigured` is a convenience the UI uses to disable the
177 * toggle with a "Configure an AI provider first" hint when no admin
178 * on the site has wired up an API key. It's a UX cue — the toggle
179 * stays writable even when it's `false` so an admin who's about to
180 * configure the provider can flip this on first.
181 */
182 function desktop_mode_comments_ai_register_rest_route() {
183 register_rest_route(
184 'desktop-mode/v1',
185 '/comments/ai-settings',
186 array(
187 array(
188 'methods' => WP_REST_Server::READABLE,
189 'callback' => 'desktop_mode_comments_ai_rest_get',
190 'permission_callback' => static function () {
191 return current_user_can( 'manage_options' );
192 },
193 ),
194 array(
195 'methods' => WP_REST_Server::CREATABLE,
196 'callback' => 'desktop_mode_comments_ai_rest_post',
197 'permission_callback' => static function () {
198 return current_user_can( 'manage_options' );
199 },
200 'args' => array(
201 'enabled' => array(
202 'required' => true,
203 'type' => 'boolean',
204 ),
205 ),
206 ),
207 )
208 );
209 }
210 add_action( 'rest_api_init', 'desktop_mode_comments_ai_register_rest_route' );
211
212 /**
213 * REST GET handler — returns the current state + provider hint.
214 *
215 * @return WP_REST_Response
216 */
217 function desktop_mode_comments_ai_rest_get() {
218 return new WP_REST_Response(
219 array(
220 'enabled' => desktop_mode_comments_ai_is_enabled(),
221 'providerConfigured' => desktop_mode_comments_ai_provider_configured(),
222 ),
223 200
224 );
225 }
226
227 /**
228 * REST POST handler — updates the toggle.
229 *
230 * @param WP_REST_Request $request Request.
231 * @return WP_REST_Response
232 */
233 function desktop_mode_comments_ai_rest_post( WP_REST_Request $request ) {
234 $enabled = (bool) $request['enabled'];
235 update_option( DESKTOP_MODE_COMMENTS_AI_OPTION, $enabled, false );
236
237 /**
238 * Fires after the Comments AI moderation toggle is changed.
239 *
240 * @param bool $enabled New state.
241 */
242 do_action( 'desktop_mode_comments_ai_toggled', $enabled );
243
244 return new WP_REST_Response(
245 array(
246 'enabled' => $enabled,
247 'providerConfigured' => desktop_mode_comments_ai_provider_configured(),
248 ),
249 200
250 );
251 }
252
253 /**
254 * Whether a usable AI text-generation provider is configured in Connectors.
255 *
256 * Delegates to the AI Copilot's capability check
257 * ({@see desktop_mode_ai_provider_configured()}), which inspects the WordPress
258 * AI Client's provider registry without making a network request. Returns
259 * `false` when the AI Copilot bundle isn't loaded.
260 *
261 * @return bool
262 */
263 function desktop_mode_comments_ai_provider_configured() {
264 return function_exists( 'desktop_mode_ai_provider_configured' )
265 && desktop_mode_ai_provider_configured();
266 }
267