Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add lib/ with reusable site owner and bell notification helpers
3 changes: 3 additions & 0 deletions projects/packages/jetpack-mu-wpcom/phpunit.11.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<testsuite name="common">
<directory suffix="Test.php">tests/php/common</directory>
</testsuite>
<testsuite name="lib">
<directory suffix="Test.php">tests/php/lib</directory>
</testsuite>
</testsuites>

<source>
Expand Down
3 changes: 3 additions & 0 deletions projects/packages/jetpack-mu-wpcom/phpunit.9.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@
<testsuite name="common">
<directory suffix="Test.php">tests/php/common</directory>
</testsuite>
<testsuite name="lib">
<directory suffix="Test.php">tests/php/lib</directory>
</testsuite>
</testsuites>
</phpunit>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static function init() {
// Shared code for src/features.
require_once self::PKG_DIR . 'src/common/index.php'; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
require_once __DIR__ . '/utils.php';
require_once __DIR__ . '/lib/load.php';

// Load features that don't need any special loading considerations.
add_action( 'plugins_loaded', array( __CLASS__, 'load_features' ) );
Expand Down
85 changes: 85 additions & 0 deletions projects/packages/jetpack-mu-wpcom/src/lib/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# WPCom Lib — Reusable Operations

Generic helper functions that work on both Simple and Atomic/WoW WordPress.com sites. Use these instead of reimplementing platform-specific logic.

## Available Operations

### Site Owner (`lib/site-owner.php`)

| Function | Returns | Description |
|----------|---------|-------------|
| `wpcom_get_site_owner_id()` | `int` | Plan/site owner user ID. Returns `0` on self-hosted. |
| `wpcom_is_site_owner()` | `bool` | Whether the current user is the site owner. |

**Environment behavior:**
- **Simple:** uses `wpcom_get_blog_owner()`
- **Atomic:** uses Jetpack `master_user` option
- **Self-hosted:** returns `0` / `false`

**Example:**
```php
$owner_id = wpcom_get_site_owner_id();
if ( $owner_id ) {
$owner = get_userdata( $owner_id );
// send notification to $owner
}
```

### Admin Notifications (`lib/admin-notifications.php`)

| Function | Returns | Description |
|----------|---------|-------------|
| `wpcom_send_bell_notification( $recipient_id, $type, $data, $dedup_key )` | `void` | Send a WP.com bell notification. No-ops if unavailable. |

**`wpcom_send_bell_notification` parameters:**
- `$recipient_id` (int) — User ID to notify
- `$type` (string) — Notification type identifier (e.g. `'rtc_collaborator_blocked'`)
- `$data` (array) — Payload attached to the notification
- `$dedup_key` (string) — Deduplication key; repeat calls update the existing note

**Example:**
```php
$owner_id = wpcom_get_site_owner_id();

wpcom_send_bell_notification(
$owner_id,
'my_feature_event',
array( 'blog_id' => get_current_blog_id(), 'post_id' => $post_id ),
sprintf( 'my-feature-%d-%d', get_current_blog_id(), $post_id )
);
```

### Blog Transients (`lib/transients.php`)

| Function | Returns | Description |
|----------|---------|-------------|
| `wpcom_set_blog_transient( $key, $value, $expiration )` | `bool` | Set a blog-scoped transient. |
| `wpcom_get_blog_transient( $key )` | `mixed` | Get a blog-scoped transient. Returns `false` if not set. |
| `wpcom_delete_blog_transient( $key )` | `bool` | Delete a blog-scoped transient. |

**Why use these instead of `set_transient()` directly?** Intent clarity. These wrappers make it explicit that the data is per-blog. On Simple sites (multisite), `set_transient()` already stores per-blog in each blog's `wp_options` table. On Atomic (single-site), there is only one blog. The wrappers add no runtime logic.

**Example:**
```php
// Rate-limit an action to once per day per blog.
if ( ! wpcom_get_blog_transient( 'my_feature_throttle' ) ) {
wpcom_set_blog_transient( 'my_feature_throttle', 1, DAY_IN_SECONDS );
// Do the thing...
}
```

## Testing

Tests are in `tests/php/lib/`. Run with:
```bash
cd projects/packages/jetpack-mu-wpcom
composer phpunit -- --testsuite=lib
```

## Adding New Operations

1. Create a new file in `src/lib/` (one file per domain)
2. Use the `wpcom_` function prefix
3. Add a `require_once` in `src/lib/load.php`
4. Add tests in `tests/php/lib/`
5. Document the function in this AGENTS.md file
66 changes: 66 additions & 0 deletions projects/packages/jetpack-mu-wpcom/src/lib/admin-notifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
/**
* Admin notification helpers.
*
* Generic functions for sending notifications to WordPress.com users.
* Works on Simple and Atomic/WoW sites; gracefully no-ops when
* platform APIs are unavailable.
*
* @package automattic/jetpack-mu-wpcom
*/

/**
* Send a WordPress.com bell notification.
*
* On Simple sites calls notes_send_callback directly.
* On WoW/Atomic sites calls the wpcom/v2/site-notifications REST endpoint.
* Gracefully no-ops when neither path is available.
*
* @param int $recipient_id The user ID to notify.
* @param string $type Notification type identifier (e.g. 'rtc_collaborator_blocked').
* @param array $data Arbitrary payload attached to the notification.
* @param string $dedup_key Deduplication key — repeat calls with the same key update the existing note.
*/
function wpcom_send_bell_notification( $recipient_id, $type, $data, $dedup_key ) {
// Simple sites: call directly.
if ( function_exists( 'notes_send_callback' ) ) {
notes_send_callback(

Check failure on line 27 in projects/packages/jetpack-mu-wpcom/src/lib/admin-notifications.php

View workflow job for this annotation

GitHub Actions / Static analysis

UndefError PhanUndeclaredFunction Call to undeclared function \notes_send_callback() FAQ on Phan issues: pdWQjU-Jb-p2
$recipient_id,
$type,
$data,
$dedup_key,
1, // Mark as unread.
false // Allow updating existing note.
);
return;
}

// WoW/Atomic sites: call the wpcom REST endpoint.
// Uses blog token auth — the endpoint extracts the blog_id from the token.
if ( ! class_exists( '\Automattic\Jetpack\Connection\Client' ) ) {
return;
}

if ( ! \Jetpack_Options::get_option( 'id' ) ) {
return;
}

\Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_blog(
'site-notifications',
'2',
array(
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
),
wp_json_encode(
array(
'recipient_id' => $recipient_id,
'type' => $type,
'data' => $data,
'dedup_key' => $dedup_key,
),
JSON_UNESCAPED_SLASHES
),
'wpcom'
);
}
13 changes: 13 additions & 0 deletions projects/packages/jetpack-mu-wpcom/src/lib/load.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php // phpcs:ignore Squiz.Commenting.FileComment.Missing
/**
* Load all lib files.
*
* This file is the entrypoint for the lib/ directory. It requires
* every lib file so the functions are available to all features.
*
* @package automattic/jetpack-mu-wpcom
*/

require_once __DIR__ . '/site-owner.php';
require_once __DIR__ . '/admin-notifications.php';
require_once __DIR__ . '/transients.php';
49 changes: 49 additions & 0 deletions projects/packages/jetpack-mu-wpcom/src/lib/site-owner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* Site owner resolution helpers.
*
* Works on Simple sites (via wpcom_get_blog_owner), Atomic/WoW sites
* (via Jetpack connection master_user), and gracefully returns defaults
* on self-hosted.
*
* @package automattic/jetpack-mu-wpcom
*/

/**
* Get the site/plan owner's user ID.
*
* - Simple sites: delegates to wpcom_get_blog_owner().
* - Atomic sites: reads the Jetpack connection master_user option.
* - Self-hosted: returns 0.
*
* @return int Owner user ID, or 0 if unavailable.
*/
function wpcom_get_site_owner_id() {
// Simple sites: wpcom_get_blog_owner is the canonical source.
if ( function_exists( 'wpcom_get_blog_owner' ) ) {
return (int) wpcom_get_blog_owner( get_wpcom_blog_id() );
}

// Atomic sites: the Jetpack connection master_user is the plan owner.
if ( class_exists( 'Jetpack_Options' ) ) {
$master_user = \Jetpack_Options::get_option( 'master_user' );
if ( $master_user ) {
return (int) $master_user;
}
}

return 0;
}

/**
* Check if the current user is the site/plan owner.
*
* @return bool True if the current user is the owner, false otherwise.
*/
function wpcom_is_site_owner() {
$owner_id = wpcom_get_site_owner_id();
if ( ! $owner_id ) {
return false;
}
return get_current_user_id() === $owner_id;
}
50 changes: 50 additions & 0 deletions projects/packages/jetpack-mu-wpcom/src/lib/transients.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php
/**
* Blog-scoped transient helpers.
*
* Thin wrappers around WordPress transient functions that make
* per-blog storage intent explicit. On Simple sites (multisite),
* set_transient() already stores per-blog in each blog's wp_options
* table. On Atomic (single-site), there is only one blog.
*
* These wrappers add no runtime logic — they exist to make the
* intent self-documenting for developers and AI agents.
*
* @package automattic/jetpack-mu-wpcom
*/

/**
* Set a blog-scoped transient.
*
* Stores data that is specific to the current blog. On multisite
* (Simple), this is isolated per blog automatically. On single-site
* (Atomic), there is only one blog.
*
* @param string $key Transient name. Must be 172 characters or fewer.
* @param mixed $value Transient value.
* @param int $expiration Time until expiration in seconds. Default 0 (no expiration).
* @return bool True if the value was set, false otherwise.
*/
function wpcom_set_blog_transient( $key, $value, $expiration = 0 ) {
return set_transient( $key, $value, $expiration );
}

/**
* Get a blog-scoped transient.
*
* @param string $key Transient name.
* @return mixed Transient value or false if not set / expired.
*/
function wpcom_get_blog_transient( $key ) {
return get_transient( $key );
}

/**
* Delete a blog-scoped transient.
*
* @param string $key Transient name.
* @return bool True if the transient was deleted, false otherwise.
*/
function wpcom_delete_blog_transient( $key ) {
return delete_transient( $key );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
/**
* Admin Notifications Lib Tests.
*
* @package automattic/jetpack-mu-wpcom
*/

declare( strict_types = 1 );

use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Constants;

/**
* Tests for admin notification lib functions.
*/
class Admin_Notifications_Test extends \WorDBless\BaseTestCase {

/**
* Tests that bell notification no-ops when notes_send_callback is unavailable
* and no Jetpack site ID is set.
*/
public function test_bell_notification_noop_without_notes_function_or_site_id() {
// notes_send_callback does not exist in this test environment,
// and no Jetpack site ID is configured.
// Should not throw — gracefully skips.
\Jetpack_Options::delete_option( 'id' );
wpcom_send_bell_notification( 1, 'test_type', array( 'key' => 'value' ), 'dedup-1' );
$this->assertTrue( true );
}

/**
* Tests that bell notification calls the wpcom API when notes_send_callback
* is unavailable but a Jetpack site ID is set (WoW/Atomic path).
*/
public function test_bell_notification_calls_wpcom_api_on_wow() {
if ( ! class_exists( Client::class ) ) {
$this->markTestSkipped( 'Jetpack Connection Client not available' );
}

Constants::set_constant( 'JETPACK__WPCOM_JSON_API_BASE', 'https://public-api.wordpress.com' );

$user_id = wp_insert_user(
array(
'user_login' => 'bell_test_user',
'user_pass' => 'password',
'role' => 'administrator',
)
);
wp_set_current_user( $user_id );

\Jetpack_Options::update_option( 'blog_token', 'test.blogtoken' );
\Jetpack_Options::update_option( 'id', 12345 );

// Hook into pre_http_request to capture the outgoing request.
$captured = null;
$filter = function ( $preempt, $args, $url ) use ( &$captured ) {
$captured = array(
'url' => $url,
'args' => $args,
);
// Short-circuit the HTTP request.
return array(
'response' => array( 'code' => 200 ),
'body' => '{"success":true}',
);
};
add_filter( 'pre_http_request', $filter, 10, 3 );

wpcom_send_bell_notification(
42,
'rtc_collaborator_blocked',
array( 'post_id' => 99 ),
'dedup-key-1'
);

remove_filter( 'pre_http_request', $filter );
\Jetpack_Options::delete_option( 'id' );
\Jetpack_Options::delete_option( 'blog_token' );
Constants::clear_single_constant( 'JETPACK__WPCOM_JSON_API_BASE' );

$this->assertNotNull( $captured, 'Expected an HTTP request to be made' );
$this->assertStringContainsString( '/site-notifications', $captured['url'] );
$this->assertStringNotContainsString( '/sites/', $captured['url'] );

$body = json_decode( $captured['args']['body'], true );
$this->assertSame( 42, $body['recipient_id'] );

Check failure on line 86 in projects/packages/jetpack-mu-wpcom/tests/php/lib/Admin_Notifications_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeArraySuspiciousNullable Suspicious array access to $body of nullable type array|bool|float|int|null|string FAQ on Phan issues: pdWQjU-Jb-p2
$this->assertSame( 'rtc_collaborator_blocked', $body['type'] );

Check failure on line 87 in projects/packages/jetpack-mu-wpcom/tests/php/lib/Admin_Notifications_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeArraySuspiciousNullable Suspicious array access to $body of nullable type array|bool|float|int|null|string FAQ on Phan issues: pdWQjU-Jb-p2
$this->assertSame( array( 'post_id' => 99 ), $body['data'] );

Check failure on line 88 in projects/packages/jetpack-mu-wpcom/tests/php/lib/Admin_Notifications_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeArraySuspiciousNullable Suspicious array access to $body of nullable type array|bool|float|int|null|string FAQ on Phan issues: pdWQjU-Jb-p2
$this->assertSame( 'dedup-key-1', $body['dedup_key'] );

Check failure on line 89 in projects/packages/jetpack-mu-wpcom/tests/php/lib/Admin_Notifications_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeArraySuspiciousNullable Suspicious array access to $body of nullable type array|bool|float|int|null|string FAQ on Phan issues: pdWQjU-Jb-p2
}
}
Loading
Loading