Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.61% covered (warning)
82.61%
152 / 184
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Monitor_Abilities
82.61% covered (warning)
82.61%
152 / 184
61.54% covered (warning)
61.54%
8 / 13
40.08
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_category
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
1
 can_view_monitor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_monitor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_monitor_status
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 set_notifications
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
9.01
 is_user_connected_to_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 apply_notifications_update
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 fetch_notifications_state
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 fetch_last_status_change
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 normalize_last_status_change
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Jetpack Monitor Abilities Registration
4 *
5 * Registers Jetpack Downtime Monitor abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack
8 */
9
10// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
11
12namespace Automattic\Jetpack\Plugin\Abilities;
13
14use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15use Automattic\Jetpack\WP_Abilities\Registrar;
16use Jetpack;
17use Jetpack_IXR_Client;
18
19/**
20 * Registers Jetpack Downtime Monitor abilities with the WordPress Abilities API.
21 *
22 * Exposes a zero-arg overview read (`get-monitor-status`) and a declarative
23 * state-setter (`set-notifications`) so AI agents can inspect and configure the
24 * site's Downtime Monitor through the standard `wp-abilities/v1` REST surface.
25 */
26class Monitor_Abilities extends Registrar {
27
28    private const MODULE_SLUG = 'monitor';
29
30    /**
31     * {@inheritDoc}
32     *
33     * Monitor abilities live under the WordPress core `site` category — it is
34     * registered by the Abilities API itself, so we reference it by slug and
35     * never register it ourselves (see the no-op `register_category()` below).
36     */
37    public static function get_category_slug(): string {
38        return 'site';
39    }
40
41    /**
42     * {@inheritDoc}
43     *
44     * Unused: the `site` category is owned by WordPress core, so
45     * `register_category()` is a no-op and this definition is never passed to
46     * `wp_register_ability_category()`. It remains only to satisfy the abstract
47     * Registrar contract.
48     */
49    public static function get_category_definition(): array {
50        return array();
51    }
52
53    /**
54     * No-op: the `site` ability category is registered by the WordPress core
55     * Abilities API. Re-registering it here would clobber the core definition,
56     * so this registrar only references the category by slug.
57     *
58     * @return void
59     */
60    public static function register_category() {}
61
62    /**
63     * {@inheritDoc}
64     */
65    public static function get_abilities(): array {
66        return array(
67            'jetpack-monitor/get-monitor-status' => array(
68                'label'               => __( 'Get Jetpack Monitor status', 'jetpack' ),
69                'description'         => __( 'Return the current Downtime Monitor state as { module_active, user_connected, notifications_enabled, last_status_change }. notifications_enabled is a boolean (does the current user receive downtime alerts). last_status_change is the timestamp of the most recent up/down status transition recorded by the Monitor service, as a "YYYY-MM-DD HH:mm:ss" UTC string, or null when no transition has been recorded — this reflects the legacy last_status_change projection, not necessarily the last time downtime began. Fails with jetpack_monitor_not_connected when the current user is not connected to Jetpack (connect first via the My Jetpack admin page), or jetpack_monitor_service_unreachable when the remote Monitor service cannot be reached. These abilities are only registered while the Monitor module is active; if they are absent from wp_get_abilities(), activate the Monitor module first.', 'jetpack' ),
70                'input_schema'        => array(
71                    'type'                 => 'object',
72                    'additionalProperties' => false,
73                ),
74                'output_schema'       => array(
75                    'type'       => 'object',
76                    'properties' => array(
77                        'module_active'         => array( 'type' => 'boolean' ),
78                        'user_connected'        => array( 'type' => 'boolean' ),
79                        'notifications_enabled' => array( 'type' => 'boolean' ),
80                        'last_status_change'    => array( 'type' => array( 'string', 'null' ) ),
81                    ),
82                ),
83                'execute_callback'    => array( __CLASS__, 'get_monitor_status' ),
84                'permission_callback' => array( __CLASS__, 'can_view_monitor' ),
85                'meta'                => array(
86                    'annotations'  => array(
87                        'readonly'    => true,
88                        'destructive' => false,
89                        'idempotent'  => true,
90                    ),
91                    'show_in_rest' => true,
92                    'mcp'          => array(
93                        'public' => true,
94                        'type'   => 'tool',
95                    ),
96                ),
97            ),
98
99            'jetpack-monitor/set-notifications'  => array(
100                'label'               => __( 'Set Jetpack Monitor notifications', 'jetpack' ),
101                'description'         => __( 'Enable or disable downtime email notifications for the current user. Idempotent — setting the state to the current value returns changed=false. Returns { enabled, changed }. Preconditions: the Monitor module must be active and the current user must be connected to Jetpack; call jetpack-monitor/get-monitor-status first to verify the connection. Fails with jetpack_monitor_module_inactive (activate the Monitor module first — these abilities are only registered while the module is active, so this error indicates a race) or jetpack_monitor_not_connected when preconditions are not met.', 'jetpack' ),
102                'input_schema'        => array(
103                    'type'                 => 'object',
104                    'required'             => array( 'enabled' ),
105                    'properties'           => array(
106                        'enabled' => array(
107                            'type'        => 'boolean',
108                            'description' => __( 'Desired notification state. true enables downtime email notifications for the current user; false disables them.', 'jetpack' ),
109                        ),
110                    ),
111                    'additionalProperties' => false,
112                ),
113                'output_schema'       => array(
114                    'type'       => 'object',
115                    'properties' => array(
116                        'enabled' => array( 'type' => 'boolean' ),
117                        'changed' => array( 'type' => 'boolean' ),
118                    ),
119                ),
120                'execute_callback'    => array( __CLASS__, 'set_notifications' ),
121                'permission_callback' => array( __CLASS__, 'can_manage_monitor' ),
122                'meta'                => array(
123                    'annotations'  => array(
124                        'readonly'    => false,
125                        'destructive' => false,
126                        'idempotent'  => true,
127                    ),
128                    'show_in_rest' => true,
129                    'mcp'          => array(
130                        'public' => true,
131                        'type'   => 'tool',
132                    ),
133                ),
134            ),
135        );
136    }
137
138    /**
139     * Permission check: can the current user read Monitor status?
140     */
141    public static function can_view_monitor(): bool {
142        return current_user_can( 'jetpack_admin_page' );
143    }
144
145    /**
146     * Permission check: can the current user manage Monitor notifications?
147     *
148     * Notifications are a per-user preference that affects the caller's own inbox,
149     * so `jetpack_admin_page` (the same capability that gates the admin settings UI)
150     * is the right gate — no stricter cap is warranted.
151     */
152    public static function can_manage_monitor(): bool {
153        return current_user_can( 'jetpack_admin_page' );
154    }
155
156    /**
157     * Execute: overview read. Returns the full
158     * `{ module_active, user_connected, notifications_enabled, last_status_change }`
159     * shape on the happy path. Surfaces precondition and transport failures as
160     * `WP_Error` so callers (especially AI agents) get an actionable next step
161     * instead of opaque null fields:
162     *
163     * - `jetpack_monitor_module_inactive` — Monitor module is not active.
164     *   Defensive: in practice this is unreachable because the abilities are
165     *   only registered while the module is active.
166     * - `jetpack_monitor_not_connected` — the current user is not connected to
167     *   Jetpack; the remote read needs the user's token. Steers the caller to
168     *   the My Jetpack admin page to connect.
169     * - `jetpack_monitor_service_unreachable` — the remote Monitor service
170     *   returned an error for one of the two underlying XML-RPC reads
171     *   (`isUserInNotifications` or `getLastDowntime`). Transient — retry later.
172     *
173     * `last_status_change` remains `null` on the happy path when no up/down
174     * transition has been recorded yet; that is the documented "no data yet"
175     * signal, not a failure.
176     *
177     * @param array|null $input Ability input (no parameters accepted).
178     * @return array|\WP_Error
179     */
180    public static function get_monitor_status( $input = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Abilities API contract requires execute callbacks to accept the input array even when the schema declares no parameters.
181        $module_active  = Jetpack::is_module_active( self::MODULE_SLUG );
182        $user_connected = static::is_user_connected_to_jetpack();
183
184        if ( ! $module_active ) {
185            return new \WP_Error(
186                'jetpack_monitor_module_inactive',
187                __( 'The Monitor module is not active. Activate it before reading Monitor status.', 'jetpack' )
188            );
189        }
190
191        if ( ! $user_connected ) {
192            return new \WP_Error(
193                'jetpack_monitor_not_connected',
194                __( 'User is not connected to Jetpack. Connect first via the My Jetpack admin page, then retry this ability.', 'jetpack' )
195            );
196        }
197
198        $state = static::fetch_notifications_state();
199        if ( is_wp_error( $state ) ) {
200            return new \WP_Error(
201                'jetpack_monitor_service_unreachable',
202                __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
203                array( 'underlying' => $state->get_error_code() )
204            );
205        }
206
207        $status_change = static::fetch_last_status_change();
208        if ( is_wp_error( $status_change ) ) {
209            return new \WP_Error(
210                'jetpack_monitor_service_unreachable',
211                __( 'The remote Jetpack Monitor service is unreachable. Retry shortly; this is typically transient.', 'jetpack' ),
212                array( 'underlying' => $status_change->get_error_code() )
213            );
214        }
215
216        return array(
217            'module_active'         => $module_active,
218            'user_connected'        => $user_connected,
219            'notifications_enabled' => (bool) $state,
220            'last_status_change'    => $status_change,
221        );
222    }
223
224    /**
225     * Execute: declarative state-setter. Idempotent — compares desired vs current
226     * and returns changed=false when they match. Either way the local
227     * `monitor_receive_notifications` option is synced to the remote value (after
228     * the write on a change, and on the no-op path) so the legacy REST reader,
229     * which trusts that option first, never reports a stale state.
230     *
231     * @param array|null $input Input matching the ability's input_schema.
232     * @return array|\WP_Error
233     */
234    public static function set_notifications( $input = null ) {
235        $input = is_array( $input ) ? $input : array();
236
237        if ( ! array_key_exists( 'enabled', $input ) ) {
238            return new \WP_Error(
239                'jetpack_monitor_missing_enabled',
240                __( 'A desired enabled state (boolean) is required.', 'jetpack' )
241            );
242        }
243        if ( ! is_bool( $input['enabled'] ) ) {
244            return new \WP_Error(
245                'jetpack_monitor_invalid_enabled',
246                __( 'The enabled parameter must be a boolean. Strings like "true" / "false" are not accepted.', 'jetpack' )
247            );
248        }
249
250        if ( ! Jetpack::is_module_active( self::MODULE_SLUG ) ) {
251            return new \WP_Error(
252                'jetpack_monitor_module_inactive',
253                __( 'The Monitor module is not active. Activate it before configuring notifications.', 'jetpack' )
254            );
255        }
256
257        if ( ! static::is_user_connected_to_jetpack() ) {
258            return new \WP_Error(
259                'jetpack_monitor_not_connected',
260                __( 'The current user is not connected to Jetpack. Connect the user to Jetpack before configuring Monitor notifications.', 'jetpack' )
261            );
262        }
263
264        $desired = $input['enabled'];
265        $current = static::fetch_notifications_state();
266        if ( is_wp_error( $current ) ) {
267            return $current;
268        }
269
270        if ( $desired === $current ) {
271            // Sync the local `monitor_receive_notifications` option to the
272            // known-good remote value even on a no-op. The legacy
273            // `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader trusts
274            // this option before falling back to a remote read, so a stale local
275            // value would let it report the wrong state. The changed=true path
276            // below mirrors the option after a write; mirroring here keeps the
277            // unchanged path self-healing too.
278            update_option( 'monitor_receive_notifications', $current );
279
280            return array(
281                'enabled' => $current,
282                'changed' => false,
283            );
284        }
285
286        $applied = static::apply_notifications_update( $desired );
287        if ( is_wp_error( $applied ) ) {
288            return $applied;
289        }
290
291        // Mirror the write to the `monitor_receive_notifications` option so the
292        // legacy `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader — the
293        // only other reader of this option — stays in sync with the remote state.
294        update_option( 'monitor_receive_notifications', $desired );
295
296        return array(
297            'enabled' => $desired,
298            'changed' => true,
299        );
300    }
301
302    /**
303     * Whether the current user is connected to Jetpack.
304     *
305     * Extracted as a protected seam so tests can override the connection check
306     * without standing up a full Jetpack token fixture.
307     */
308    protected static function is_user_connected_to_jetpack(): bool {
309        return ( new Connection_Manager( 'jetpack' ) )->is_user_connected();
310    }
311
312    /**
313     * Send the IXR `jetpack.monitor.setNotifications` request to apply the
314     * desired state on the remote Monitor service.
315     *
316     * @param bool $enabled Desired notification state.
317     * @return true|\WP_Error True on success, WP_Error on remote failure.
318     */
319    protected static function apply_notifications_update( bool $enabled ) {
320        $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
321        $xml->query( 'jetpack.monitor.setNotifications', $enabled );
322        if ( $xml->isError() ) {
323            return new \WP_Error(
324                'jetpack_monitor_notifications_update_failed',
325                sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
326            );
327        }
328        return true;
329    }
330
331    /**
332     * Fetch the current notifications state from the remote Monitor service.
333     *
334     * @return bool|\WP_Error Boolean preference when the remote call succeeds,
335     *                        WP_Error when the remote call fails.
336     */
337    protected static function fetch_notifications_state() {
338        $xml = new Jetpack_IXR_Client( array( 'user_id' => get_current_user_id() ) );
339        $xml->query( 'jetpack.monitor.isUserInNotifications' );
340        if ( $xml->isError() ) {
341            return new \WP_Error(
342                'jetpack_monitor_notifications_data_unavailable',
343                sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
344            );
345        }
346        return (bool) $xml->getResponse();
347    }
348
349    /**
350     * Fetch the last up/down status-change timestamp from the remote Monitor
351     * service, reusing the same transient key and 10-minute TTL written by the
352     * legacy module.
353     *
354     * The remote `jetpack.monitor.getLastDowntime` XML-RPC method returns the
355     * legacy `last_status_change` projection — the time of the most recent
356     * up/down transition, not strictly when downtime began. The transient key
357     * stays `monitor_last_downtime` because that is what the legacy module
358     * writes and we share its cache.
359     *
360     * @return string|null|\WP_Error YYYY-MM-DD HH:mm:ss string, null when no
361     *                               transition has been recorded, or WP_Error
362     *                               on a remote failure.
363     */
364    protected static function fetch_last_status_change() {
365        $cached = get_transient( 'monitor_last_downtime' );
366        if ( false !== $cached ) {
367            return self::normalize_last_status_change( $cached );
368        }
369
370        $xml = new Jetpack_IXR_Client();
371        $xml->query( 'jetpack.monitor.getLastDowntime' );
372        if ( $xml->isError() ) {
373            return new \WP_Error(
374                'jetpack_monitor_downtime_data_unavailable',
375                sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() )
376            );
377        }
378
379        $response = $xml->getResponse();
380        set_transient( 'monitor_last_downtime', $response, 10 * MINUTE_IN_SECONDS );
381        return self::normalize_last_status_change( $response );
382    }
383
384    /**
385     * Normalize a `last_status_change` value into the documented contract:
386     * a `YYYY-MM-DD HH:mm:ss` UTC string, or `null` for "no transition yet".
387     *
388     * Jetpack Monitor v1 returns an empty string when no transition has been
389     * recorded; Monitor v2 may instead surface a MySQL zero-date
390     * (`0000-00-00 00:00:00`) or some other sentinel. Collapse every "no value"
391     * representation to `null` so the ability's `null` contract stays stable
392     * regardless of which backend is active.
393     *
394     * @param mixed $value Raw remote/cached value.
395     * @return string|null Pass-through timestamp string, or null when absent.
396     */
397    protected static function normalize_last_status_change( $value ) {
398        if ( ! is_string( $value ) ) {
399            return null;
400        }
401
402        $value = trim( $value );
403        if ( '' === $value || 0 === strncmp( $value, '0000-00-00', 10 ) ) {
404            return null;
405        }
406
407        $ts = strtotime( $value );
408        if ( false === $ts || $ts <= 0 ) {
409            return null;
410        }
411
412        return $value;
413    }
414}