updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -7,8 +7,10 @@
|
||||
|
||||
namespace Activitypub\Scheduler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Extra_Fields;
|
||||
use Activitypub\Collection\Outbox;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\is_user_type_disabled;
|
||||
@ -48,6 +50,13 @@ class Actor {
|
||||
\add_action( 'update_option_activitypub_actor_mode', array( self::class, 'blog_user_update' ) );
|
||||
|
||||
\add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 );
|
||||
|
||||
\add_action( 'post_stuck', array( self::class, 'sticky_post_update' ) );
|
||||
\add_action( 'post_unstuck', array( self::class, 'sticky_post_update' ) );
|
||||
|
||||
// User deletion handling.
|
||||
\add_action( 'delete_user', array( self::class, 'schedule_user_delete' ), 10, 3 );
|
||||
\add_filter( 'post_types_to_delete_with_user', array( self::class, 'post_types_to_delete_with_user' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,4 +152,53 @@ class Actor {
|
||||
|
||||
add_to_outbox( $actor, 'Update', $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a profile update when a post's sticky status changes.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
public static function sticky_post_update( $post_id ) {
|
||||
$post = \get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::schedule_profile_update( $post->post_author );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a Delete activity when a user is deleted.
|
||||
*
|
||||
* @param int $user_id The user ID being deleted.
|
||||
*/
|
||||
public static function schedule_user_delete( $user_id ) {
|
||||
// Get the actor before deletion to ensure we have the data.
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_actor( $actor->get_id() );
|
||||
$activity->set_object( $actor->get_id() );
|
||||
$activity->set_type( 'Delete' );
|
||||
|
||||
add_to_outbox( $activity, null, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove outbox from post types to delete with user.
|
||||
*
|
||||
* Outbox items should not be deleted with the user, because we
|
||||
* need to federate the `Delete` Activities.
|
||||
*
|
||||
* @param array $post_types The post types to delete with user.
|
||||
*
|
||||
* @return array The post types to delete with user without outbox.
|
||||
*/
|
||||
public static function post_types_to_delete_with_user( $post_types ) {
|
||||
return \array_diff( $post_types, array( Outbox::POST_TYPE ) );
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* Collection Sync Scheduler.
|
||||
*
|
||||
* Handles async reconciliation when FEP-8fcf Collection-Synchronization
|
||||
* digest mismatches are detected.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Scheduler;
|
||||
|
||||
use Activitypub\Collection\Following;
|
||||
use Activitypub\Http;
|
||||
|
||||
use function Activitypub\get_url_authority;
|
||||
|
||||
/**
|
||||
* Collection_Sync class.
|
||||
*/
|
||||
class Collection_Sync {
|
||||
/**
|
||||
* Initialize the scheduler.
|
||||
*/
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_collection_sync', array( self::class, 'schedule_reconciliation' ), 10, 4 );
|
||||
\add_action( 'activitypub_followers_sync_reconcile', array( self::class, 'reconcile_followers' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconciliation job.
|
||||
*
|
||||
* @param string $type The collection type (e.g., 'followers').
|
||||
* @param int $user_id The local user ID.
|
||||
* @param string $actor_url The remote actor URL.
|
||||
* @param array $params The Collection-Synchronization header parameters.
|
||||
*/
|
||||
public static function schedule_reconciliation( $type, $user_id, $actor_url, $params ) {
|
||||
// Schedule async processing to avoid blocking the inbox.
|
||||
\wp_schedule_single_event(
|
||||
time() + MINUTE_IN_SECONDS,
|
||||
"activitypub_{$type}_sync_reconcile",
|
||||
array( $user_id, $actor_url, $params )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile followers based on remote partial collection.
|
||||
*
|
||||
* @param int $user_id The local user ID.
|
||||
* @param string $actor_url The remote actor URL.
|
||||
* @param array $params The Collection-Synchronization header parameters.
|
||||
*/
|
||||
public static function reconcile_followers( $user_id, $actor_url, $params ) {
|
||||
if ( empty( $params['url'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the authoritative partial followers collection.
|
||||
$data = Http::get_remote_object( $params['url'], 5 * MINUTE_IN_SECONDS );
|
||||
|
||||
if ( \is_wp_error( $data ) || ! isset( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$remote_followers = $data['orderedItems'];
|
||||
|
||||
// Get our authority.
|
||||
$home_authority = get_url_authority( \home_url() );
|
||||
|
||||
$accepted = Following::get_by_authority( $user_id, $home_authority );
|
||||
foreach ( $accepted as $following ) {
|
||||
$key = array_search( $following->guid, $remote_followers, true );
|
||||
if ( false === $key ) {
|
||||
Following::reject( $following, $user_id );
|
||||
} else {
|
||||
unset( $remote_followers[ $key ] );
|
||||
}
|
||||
}
|
||||
|
||||
$remote_followers = array_values( $remote_followers ); // Reindex.
|
||||
|
||||
$pending = Following::get_by_authority( $user_id, $home_authority, Following::PENDING_META_KEY );
|
||||
foreach ( $pending as $following ) {
|
||||
$key = array_search( $following->guid, $remote_followers, true );
|
||||
if ( false === $key ) {
|
||||
Following::reject( $following, $user_id );
|
||||
} else {
|
||||
Following::accept( $following, $user_id );
|
||||
unset( $remote_followers[ $key ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action triggered after reconciliation is complete.
|
||||
*
|
||||
* @param int $user_id The local user ID that triggered the reconciliation.
|
||||
* @param string $actor_url The remote actor URL.
|
||||
*/
|
||||
\do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url );
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@
|
||||
|
||||
namespace Activitypub\Scheduler;
|
||||
|
||||
use Activitypub\Comment as Comment_Utils;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\should_comment_be_federated;
|
||||
|
||||
@ -25,6 +27,7 @@ class Comment {
|
||||
// Comment transitions.
|
||||
\add_action( 'transition_comment_status', array( self::class, 'schedule_comment_activity' ), 20, 3 );
|
||||
\add_action( 'wp_insert_comment', array( self::class, 'schedule_comment_activity_on_insert' ), 10, 2 );
|
||||
\add_action( 'delete_comment', array( self::class, 'schedule_comment_delete_activity' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,6 +51,24 @@ class Comment {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Check against supported comment types.
|
||||
* Only federate registered ActivityPub comment types and standard WordPress comments.
|
||||
*/
|
||||
$comment_type = $comment->comment_type;
|
||||
if ( '' === $comment_type ) {
|
||||
// Be backwards compatible with comments that have an empty type by treating them as standard comments.
|
||||
$comment_type = 'comment';
|
||||
}
|
||||
|
||||
$allowed_types = Comment_Utils::get_comment_type_slugs();
|
||||
$allowed_types[] = 'comment'; // Add core WordPress comment types.
|
||||
|
||||
// Check if comment type is in allowed list.
|
||||
if ( ! in_array( $comment_type, $allowed_types, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = false;
|
||||
|
||||
if (
|
||||
@ -60,6 +81,7 @@ class Comment {
|
||||
\update_comment_meta( $comment->comment_ID, 'activitypub_comment_modified', time(), true );
|
||||
} elseif (
|
||||
'trash' === $new_status ||
|
||||
( 'delete' === $new_status && '' === $old_status ) || // Went through schedule_comment_delete_activity().
|
||||
'spam' === $new_status
|
||||
) {
|
||||
$type = 'Delete';
|
||||
@ -88,4 +110,17 @@ class Comment {
|
||||
self::schedule_comment_activity( 'approved', '', $comment );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule Delete activity when a comment is permanently deleted.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param \WP_Comment $comment Comment object.
|
||||
*/
|
||||
public static function schedule_comment_delete_activity( $comment_id, $comment ) {
|
||||
// Only send Delete activities for comments that were previously federated.
|
||||
if ( Comment_Utils::was_sent( $comment ) ) {
|
||||
self::schedule_comment_activity( 'delete', '', $comment );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,12 @@
|
||||
|
||||
namespace Activitypub\Scheduler;
|
||||
|
||||
use Activitypub\Activity\Activity;
|
||||
use Activitypub\Collection\Actors;
|
||||
|
||||
use function Activitypub\add_to_outbox;
|
||||
use function Activitypub\get_content_visibility;
|
||||
use function Activitypub\get_post_id;
|
||||
use function Activitypub\get_wp_object_state;
|
||||
use function Activitypub\is_post_disabled;
|
||||
|
||||
@ -20,23 +25,36 @@ class Post {
|
||||
*/
|
||||
public static function init() {
|
||||
// Post transitions.
|
||||
\add_action( 'wp_after_insert_post', array( self::class, 'schedule_post_activity' ), 33, 4 );
|
||||
\add_action( 'wp_after_insert_post', array( self::class, 'triage' ), 33, 4 );
|
||||
|
||||
// Attachment transitions.
|
||||
\add_action( 'add_attachment', array( self::class, 'transition_attachment_status' ) );
|
||||
\add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) );
|
||||
\add_action( 'delete_attachment', array( self::class, 'transition_attachment_status' ) );
|
||||
|
||||
/*
|
||||
* Sticky post transitions (featured collection).
|
||||
*
|
||||
* Note: These hooks run in addition to the legacy sticky hooks in
|
||||
* Actor scheduler, which send an Update activity when a post becomes
|
||||
* sticky or is unstuck. This means a sticky/unsticky event will cause both:
|
||||
* - an Add/Remove activity for the Actor's featured collection (below), and
|
||||
* - an Update activity (from Actor scheduler).
|
||||
* The Update activity is kept for backwards compatibility.
|
||||
*/
|
||||
\add_action( 'post_stuck', array( self::class, 'schedule_featured_add' ) );
|
||||
\add_action( 'post_unstuck', array( self::class, 'schedule_featured_remove' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle post updates and determine the appropriate Activity type.
|
||||
* Triage post transitions and determine the appropriate Activity type.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param bool $update Whether this is an existing post being updated.
|
||||
* @param \WP_Post $post_before Post object before the update.
|
||||
*/
|
||||
public static function schedule_post_activity( $post_id, $post, $update, $post_before ) {
|
||||
public static function triage( $post_id, $post, $update, $post_before ) {
|
||||
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
|
||||
return;
|
||||
}
|
||||
@ -45,8 +63,18 @@ class Post {
|
||||
return;
|
||||
}
|
||||
|
||||
$object_status = get_wp_object_state( $post );
|
||||
|
||||
// If the post is already soft-deleted, do not create any more activities.
|
||||
if (
|
||||
ACTIVITYPUB_OBJECT_STATE_DELETED === $object_status &&
|
||||
in_array( get_content_visibility( $post ), array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail on bulk edits, unless post author or post status changed.
|
||||
if ( isset( $_REQUEST['bulk_edit'] ) && -1 === (int) $_REQUEST['post_author'] && -1 === (int) $_REQUEST['_status'] ) { // phpcs:ignore WordPress
|
||||
if ( isset( $_REQUEST['bulk_edit'] ) && ( ! isset( $_REQUEST['post_author'] ) || -1 === (int) $_REQUEST['post_author'] ) && -1 === (int) $_REQUEST['_status'] ) { // phpcs:ignore WordPress
|
||||
return;
|
||||
}
|
||||
|
||||
@ -55,15 +83,20 @@ class Post {
|
||||
|
||||
switch ( $new_status ) {
|
||||
case 'publish':
|
||||
$type = ( 'publish' === $old_status ) ? 'Update' : 'Create';
|
||||
if ( $update ) {
|
||||
$type = ( 'publish' === $old_status ) ? 'Update' : 'Create';
|
||||
} else {
|
||||
$type = 'Create';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'draft':
|
||||
case 'pending':
|
||||
$type = ( 'publish' === $old_status ) ? 'Update' : false;
|
||||
break;
|
||||
|
||||
case 'trash':
|
||||
$type = 'federated' === get_wp_object_state( $post ) ? 'Delete' : false;
|
||||
$type = ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status ? 'Delete' : false;
|
||||
break;
|
||||
|
||||
default:
|
||||
@ -75,7 +108,24 @@ class Post {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the post to the outbox.
|
||||
/*
|
||||
* If the post was already federated and this is a Create, skip.
|
||||
* The outbox controller already added it to the outbox.
|
||||
*/
|
||||
if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && 'Create' === $type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the post was never federated before, it should be a Create activity.
|
||||
if ( empty( $object_status ) && 'Update' === $type ) {
|
||||
$type = 'Create';
|
||||
}
|
||||
|
||||
// If the post was federated before but is now local or private, it should be a Delete activity.
|
||||
if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && in_array( get_content_visibility( $post ), array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true ) ) {
|
||||
$type = 'Delete';
|
||||
}
|
||||
|
||||
add_to_outbox( $post, $type, $post->post_author );
|
||||
}
|
||||
|
||||
@ -93,21 +143,89 @@ class Post {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( is_post_disabled( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = \get_post( $post_id );
|
||||
|
||||
if ( ! $post instanceof \WP_Post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ( \current_action() ) {
|
||||
case 'add_attachment':
|
||||
// Add the post to the outbox.
|
||||
add_to_outbox( $post, 'Create', $post->post_author );
|
||||
$type = 'Create';
|
||||
break;
|
||||
case 'edit_attachment':
|
||||
// Update the post to the outbox.
|
||||
add_to_outbox( $post, 'Update', $post->post_author );
|
||||
$type = 'Update';
|
||||
break;
|
||||
case 'delete_attachment':
|
||||
// Delete the post from the outbox.
|
||||
add_to_outbox( $post, 'Delete', $post->post_author );
|
||||
$type = 'Delete';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
add_to_outbox( $post, $type, $post->post_author );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an Add activity when a post is added to the featured collection.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
public static function schedule_featured_add( $post_id ) {
|
||||
self::schedule_featured_update( $post_id, 'Add' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a Remove activity when a post is removed from the featured collection.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
public static function schedule_featured_remove( $post_id ) {
|
||||
self::schedule_featured_update( $post_id, 'Remove' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an Add or Remove activity for the featured collection.
|
||||
*
|
||||
* When a post's sticky status changes, this sends an Add or Remove activity
|
||||
* to notify followers about the change to the actor's featured collection.
|
||||
*
|
||||
* @see https://github.com/Automattic/wordpress-activitypub/issues/2795
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @param string $activity_type The activity type ('Add' or 'Remove').
|
||||
*/
|
||||
private static function schedule_featured_update( $post_id, $activity_type ) {
|
||||
if ( \defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = \get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( is_post_disabled( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_id( $post->post_author );
|
||||
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->set_type( $activity_type );
|
||||
$activity->set_actor( $actor->get_id() );
|
||||
$activity->set_object( get_post_id( $post->ID ) );
|
||||
$activity->set_target( $actor->get_featured() );
|
||||
|
||||
add_to_outbox( $activity, null, $post->post_author );
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,345 @@
|
||||
<?php
|
||||
/**
|
||||
* Statistics scheduler class file.
|
||||
*
|
||||
* Handles scheduled collection of ActivityPub statistics.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Scheduler;
|
||||
|
||||
use Activitypub\Mailer;
|
||||
use Activitypub\Statistics as Statistics_Collector;
|
||||
|
||||
/**
|
||||
* Statistics scheduler class.
|
||||
*/
|
||||
class Statistics {
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
\add_action( 'activitypub_collect_monthly_stats', array( self::class, 'collect_all_monthly_stats' ) );
|
||||
\add_action( 'activitypub_compile_annual_stats', array( self::class, 'compile_and_send_annual_stats' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect monthly statistics for all active users.
|
||||
*
|
||||
* This runs on the 1st of each month and collects stats for the previous month.
|
||||
*/
|
||||
public static function collect_all_monthly_stats() {
|
||||
$user_ids = Statistics_Collector::get_active_user_ids();
|
||||
|
||||
// Get previous month.
|
||||
$now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
|
||||
$prev_month = \strtotime( '-1 month', $now );
|
||||
$year = (int) \gmdate( 'Y', $prev_month );
|
||||
$month = (int) \gmdate( 'n', $prev_month );
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
Statistics_Collector::collect_monthly_stats( $user_id, $year, $month );
|
||||
self::send_monthly_email( $user_id, $year, $month );
|
||||
}
|
||||
|
||||
// Reschedule to the exact next 1st of month to prevent drift from the 30-day interval.
|
||||
$next_first = \strtotime( 'first day of next month 02:00:00', $now );
|
||||
\wp_clear_scheduled_hook( 'activitypub_collect_monthly_stats' );
|
||||
\wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' );
|
||||
|
||||
/**
|
||||
* Fires after monthly statistics have been collected for all users.
|
||||
*
|
||||
* @param int $year The year of the collected stats.
|
||||
* @param int $month The month of the collected stats.
|
||||
*/
|
||||
\do_action( 'activitypub_monthly_stats_collected', $year, $month );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile annual statistics and send notifications.
|
||||
*
|
||||
* This runs on December 1st and compiles stats for the current year
|
||||
* (through November), giving users time to share their "wrapped" stats
|
||||
* before year-end.
|
||||
*
|
||||
* @todo Create a shareable landing page instead of just sending an email.
|
||||
* The email should link to a public page where stats can be viewed
|
||||
* and shared. Consider adding a summary image generator.
|
||||
*/
|
||||
public static function compile_and_send_annual_stats() {
|
||||
$user_ids = Statistics_Collector::get_active_user_ids();
|
||||
|
||||
// Get current year (we're running in December, compiling Jan-Nov stats).
|
||||
$now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
|
||||
$year = (int) \gmdate( 'Y', $now );
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$summary = Statistics_Collector::compile_annual_summary( $user_id, $year );
|
||||
|
||||
// Send email notification.
|
||||
self::send_annual_email( $user_id, $year, $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after annual statistics have been compiled for all users.
|
||||
*
|
||||
* @param int $year The year of the compiled stats.
|
||||
*/
|
||||
\do_action( 'activitypub_annual_stats_compiled', $year );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the annual report email.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
* @param array $summary The annual summary data.
|
||||
* @param bool $force Whether to bypass user preference checks.
|
||||
*/
|
||||
public static function send_annual_email( $user_id, $year, $summary, $force = false ) {
|
||||
if ( ! $force && ! self::should_send_report( $user_id, $summary, 'activitypub_mailer_annual_report', '1' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic claim: add_option only succeeds if the row doesn't yet exist, so this
|
||||
// is race-safe across concurrent cron workers and re-entrant invocations.
|
||||
// When $force is true, we still record the marker so a later non-forced cron run
|
||||
// won't send another copy for the same period.
|
||||
$email_sent_option = self::get_email_sent_option_name( $user_id, $year );
|
||||
if ( ! \add_option( $email_sent_option, \time(), '', false ) ) {
|
||||
if ( ! $force ) {
|
||||
return;
|
||||
}
|
||||
|
||||
\update_option( $email_sent_option, \time(), false );
|
||||
}
|
||||
|
||||
// Get month name for most_active_month.
|
||||
$most_active_month_name = '';
|
||||
if ( ! empty( $summary['most_active_month'] ) ) {
|
||||
$most_active_month_name = \date_i18n( 'F', \strtotime( \sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) );
|
||||
}
|
||||
|
||||
// Build follower text.
|
||||
$followers_text = '';
|
||||
if ( ! empty( $summary['followers_start'] ) || ! empty( $summary['followers_end'] ) ) {
|
||||
$followers_text = \sprintf(
|
||||
/* translators: 1: follower count at start, 2: follower count at end */
|
||||
\__( 'From <strong>%1$s</strong> to <strong>%2$s</strong> followers', 'activitypub' ),
|
||||
\number_format_i18n( $summary['followers_start'] ?? 0 ),
|
||||
\number_format_i18n( $summary['followers_end'] ?? 0 )
|
||||
);
|
||||
}
|
||||
|
||||
// Build supporter text.
|
||||
$supporter_text = '';
|
||||
if ( ! empty( $summary['top_multiplicator'] ) ) {
|
||||
$supporter_text = \sprintf(
|
||||
/* translators: 1: supporter URL, 2: supporter name, 3: boost count */
|
||||
\__( '<strong><a href="%1$s">%2$s</a></strong> with %3$s boosts', 'activitypub' ),
|
||||
\esc_url( $summary['top_multiplicator']['url'] ),
|
||||
\esc_html( $summary['top_multiplicator']['name'] ),
|
||||
\number_format_i18n( $summary['top_multiplicator']['count'] )
|
||||
);
|
||||
}
|
||||
|
||||
$args = \array_merge(
|
||||
$summary,
|
||||
array(
|
||||
/* translators: %d: Year */
|
||||
'title' => \sprintf( \__( 'Your %d Fediverse Year in Review', 'activitypub' ), $year ),
|
||||
/* translators: %d: Year */
|
||||
'intro' => \sprintf( \__( "Here's a look back at your %d activity on the Fediverse.", 'activitypub' ), $year ),
|
||||
'closing' => \__( 'Thanks for being part of the Fediverse! Here\'s to another great year.', 'activitypub' ),
|
||||
'most_active_month_name' => $most_active_month_name,
|
||||
'followers_text' => $followers_text,
|
||||
'supporter_text' => $supporter_text,
|
||||
'user_id' => $user_id,
|
||||
)
|
||||
);
|
||||
|
||||
$subject = \sprintf(
|
||||
/* translators: 1: Blog name, 2: Year */
|
||||
\__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ),
|
||||
\esc_html( \get_option( 'blogname' ) ),
|
||||
$year
|
||||
);
|
||||
|
||||
// Build plain text alternative.
|
||||
/* translators: %d: Year */
|
||||
$alt_body = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year );
|
||||
|
||||
if ( ! empty( $args['posts_count'] ) ) {
|
||||
/* translators: %d: Number of posts */
|
||||
$alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $args['followers_net_change'] ) ) {
|
||||
/* translators: %d: Net follower change */
|
||||
$alt_body .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $most_active_month_name ) ) {
|
||||
/* translators: %s: Month name */
|
||||
$alt_body .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $most_active_month_name );
|
||||
}
|
||||
|
||||
Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the monthly stats report email.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
* @param int $month The month (1-12).
|
||||
* @param bool $force Whether to bypass user preference checks.
|
||||
*/
|
||||
public static function send_monthly_email( $user_id, $year, $month, $force = false ) {
|
||||
$option_name = Statistics_Collector::get_monthly_option_name( $user_id, $year, $month );
|
||||
$stats = \get_option( $option_name, array() );
|
||||
|
||||
if ( empty( $stats ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $force && ! self::should_send_report( $user_id, $stats, 'activitypub_mailer_monthly_report', '1' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic claim: add_option only succeeds if the row doesn't yet exist, so this
|
||||
// is race-safe across concurrent cron workers and re-entrant invocations.
|
||||
// When $force is true, we still record the marker so a later non-forced cron run
|
||||
// won't send another copy for the same period.
|
||||
$email_sent_option = self::get_email_sent_option_name( $user_id, $year, $month );
|
||||
if ( ! \add_option( $email_sent_option, \time(), '', false ) ) {
|
||||
if ( ! $force ) {
|
||||
return;
|
||||
}
|
||||
|
||||
\update_option( $email_sent_option, \time(), false );
|
||||
}
|
||||
|
||||
$month_name = \date_i18n( 'F Y', \strtotime( \sprintf( '%d-%02d-01', $year, $month ) ) );
|
||||
|
||||
// Build follower text.
|
||||
$followers_text = '';
|
||||
if ( ! empty( $stats['followers_total'] ) ) {
|
||||
$followers_text = \sprintf(
|
||||
/* translators: %s: total follower count */
|
||||
\__( 'You now have <strong>%s</strong> followers', 'activitypub' ),
|
||||
\number_format_i18n( $stats['followers_total'] )
|
||||
);
|
||||
}
|
||||
|
||||
// Build supporter text.
|
||||
$supporter_text = '';
|
||||
if ( ! empty( $stats['top_multiplicator'] ) ) {
|
||||
$supporter_text = \sprintf(
|
||||
/* translators: 1: supporter URL, 2: supporter name, 3: boost count */
|
||||
\__( '<strong><a href="%1$s">%2$s</a></strong> with %3$s boosts', 'activitypub' ),
|
||||
\esc_url( $stats['top_multiplicator']['url'] ),
|
||||
\esc_html( $stats['top_multiplicator']['name'] ),
|
||||
\number_format_i18n( $stats['top_multiplicator']['count'] )
|
||||
);
|
||||
}
|
||||
|
||||
$args = \array_merge(
|
||||
$stats,
|
||||
array(
|
||||
/* translators: %s: Month and year, e.g. "March 2025" */
|
||||
'title' => \sprintf( \__( 'Your Fediverse Stats for %s', 'activitypub' ), $month_name ),
|
||||
/* translators: %s: Month and year, e.g. "March 2025" */
|
||||
'intro' => \sprintf( \__( "Here's how your content performed on the Fediverse in %s.", 'activitypub' ), $month_name ),
|
||||
'closing' => \__( 'Keep sharing great content on the Fediverse!', 'activitypub' ),
|
||||
'followers_text' => $followers_text,
|
||||
'supporter_text' => $supporter_text,
|
||||
'user_id' => $user_id,
|
||||
)
|
||||
);
|
||||
|
||||
$subject = \sprintf(
|
||||
/* translators: 1: Blog name, 2: Month and year */
|
||||
\__( '[%1$s] Your Fediverse Stats for %2$s', 'activitypub' ),
|
||||
\esc_html( \get_option( 'blogname' ) ),
|
||||
$month_name
|
||||
);
|
||||
|
||||
// Build plain text alternative.
|
||||
/* translators: %s: Month and year */
|
||||
$alt_body = \sprintf( \__( "Here's your Fediverse stats for %s:\n\n", 'activitypub' ), $month_name );
|
||||
|
||||
if ( ! empty( $stats['posts_count'] ) ) {
|
||||
/* translators: %d: Number of posts */
|
||||
$alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $stats['posts_count'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $stats['followers_count'] ) ) {
|
||||
/* translators: %d: New follower count */
|
||||
$alt_body .= \sprintf( \__( "New followers: %+d\n", 'activitypub' ), $stats['followers_count'] );
|
||||
}
|
||||
|
||||
Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the option name used to record that a stats email has been sent for a given period.
|
||||
*
|
||||
* The presence of this option is the idempotency signal: a row exists once an email
|
||||
* has been delivered (or claimed for delivery) for the given user and period.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
* @param int|null $month The month (1-12), or null for the annual report.
|
||||
*
|
||||
* @return string The option name. Truncated to fit MySQL's 191-character key.
|
||||
*/
|
||||
private static function get_email_sent_option_name( $user_id, $year, $month = null ) {
|
||||
$suffix = null === $month ? \sprintf( '%d_annual', $year ) : \sprintf( '%d_%d', $year, $month );
|
||||
return \substr( \sprintf( 'activitypub_stats_emailed_%d_%s', $user_id, $suffix ), 0, 191 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a stats report should be sent.
|
||||
*
|
||||
* Verifies user preference and that there is meaningful activity.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $stats The stats data.
|
||||
* @param string $option_name The preference option name (same for blog and user).
|
||||
* @param string $fallback The fallback value for the blog option.
|
||||
*
|
||||
* @return bool True if the report should be sent.
|
||||
*/
|
||||
private static function should_send_report( $user_id, $stats, $option_name, $fallback = '1' ) {
|
||||
if ( empty( $stats ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check user preference.
|
||||
if ( $user_id > \Activitypub\Collection\Actors::BLOG_USER_ID ) {
|
||||
if ( ! \get_user_option( $option_name, $user_id ) ) {
|
||||
return false;
|
||||
}
|
||||
} elseif ( '1' !== \get_option( $option_name, $fallback ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that there is meaningful activity.
|
||||
if ( ! empty( $stats['posts_count'] ) || ! empty( $stats['followers_count'] ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$comment_types = \array_keys( Statistics_Collector::get_comment_types_for_stats() );
|
||||
foreach ( $comment_types as $type ) {
|
||||
if ( ! empty( $stats[ $type . '_count' ] ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user