PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / scheduler / class-statistics.php

class-statistics.php in ActivityPub 9.2.2, at includes/scheduler/class-statistics.php

347 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Statistics scheduler class file.
4 *
5 * Handles scheduled collection of ActivityPub statistics.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub\Scheduler;
11
12 use Activitypub\Collection\Actors;
13 use Activitypub\Mailer;
14 use Activitypub\Statistics as Statistics_Collector;
15
16 /**
17 * Statistics scheduler class.
18 */
19 class Statistics {
20
21 /**
22 * Initialize the class, registering WordPress hooks.
23 */
24 public static function init() {
25 \add_action( 'activitypub_collect_monthly_stats', array( self::class, 'collect_all_monthly_stats' ) );
26 \add_action( 'activitypub_compile_annual_stats', array( self::class, 'compile_and_send_annual_stats' ) );
27 }
28
29 /**
30 * Collect monthly statistics for all active users.
31 *
32 * This runs on the 1st of each month and collects stats for the previous month.
33 */
34 public static function collect_all_monthly_stats() {
35 $user_ids = Statistics_Collector::get_active_user_ids();
36
37 // Get previous month.
38 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
39 $prev_month = \strtotime( '-1 month', $now );
40 $year = (int) \gmdate( 'Y', $prev_month );
41 $month = (int) \gmdate( 'n', $prev_month );
42
43 foreach ( $user_ids as $user_id ) {
44 Statistics_Collector::collect_monthly_stats( $user_id, $year, $month );
45 self::send_monthly_email( $user_id, $year, $month );
46 }
47
48 // Reschedule to the exact next 1st of month to prevent drift from the 30-day interval.
49 $next_first = \strtotime( 'first day of next month 02:00:00', $now );
50 \wp_clear_scheduled_hook( 'activitypub_collect_monthly_stats' );
51 \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' );
52
53 /**
54 * Fires after monthly statistics have been collected for all users.
55 *
56 * @param int $year The year of the collected stats.
57 * @param int $month The month of the collected stats.
58 */
59 \do_action( 'activitypub_monthly_stats_collected', $year, $month );
60 }
61
62 /**
63 * Compile annual statistics and send notifications.
64 *
65 * This runs on December 1st and compiles stats for the current year
66 * (through November), giving users time to share their "wrapped" stats
67 * before year-end.
68 *
69 * @todo Create a shareable landing page instead of just sending an email.
70 * The email should link to a public page where stats can be viewed
71 * and shared. Consider adding a summary image generator.
72 */
73 public static function compile_and_send_annual_stats() {
74 $user_ids = Statistics_Collector::get_active_user_ids();
75
76 // Get current year (we're running in December, compiling Jan-Nov stats).
77 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
78 $year = (int) \gmdate( 'Y', $now );
79
80 foreach ( $user_ids as $user_id ) {
81 $summary = Statistics_Collector::compile_annual_summary( $user_id, $year );
82
83 // Send email notification.
84 self::send_annual_email( $user_id, $year, $summary );
85 }
86
87 /**
88 * Fires after annual statistics have been compiled for all users.
89 *
90 * @param int $year The year of the compiled stats.
91 */
92 \do_action( 'activitypub_annual_stats_compiled', $year );
93 }
94
95 /**
96 * Send the annual report email.
97 *
98 * @param int $user_id The user ID.
99 * @param int $year The year.
100 * @param array $summary The annual summary data.
101 * @param bool $force Whether to bypass user preference checks.
102 */
103 public static function send_annual_email( $user_id, $year, $summary, $force = false ) {
104 if ( ! $force && ! self::should_send_report( $user_id, $summary, 'activitypub_mailer_annual_report', '1' ) ) {
105 return;
106 }
107
108 // Atomic claim: add_option only succeeds if the row doesn't yet exist, so this
109 // is race-safe across concurrent cron workers and re-entrant invocations.
110 // When $force is true, we still record the marker so a later non-forced cron run
111 // won't send another copy for the same period.
112 $email_sent_option = self::get_email_sent_option_name( $user_id, $year );
113 if ( ! \add_option( $email_sent_option, \time(), '', false ) ) {
114 if ( ! $force ) {
115 return;
116 }
117
118 \update_option( $email_sent_option, \time(), false );
119 }
120
121 // Get month name for most_active_month.
122 $most_active_month_name = '';
123 if ( ! empty( $summary['most_active_month'] ) ) {
124 $most_active_month_name = \date_i18n( 'F', \strtotime( \sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) );
125 }
126
127 // Build follower text.
128 $followers_text = '';
129 if ( ! empty( $summary['followers_start'] ) || ! empty( $summary['followers_end'] ) ) {
130 $followers_text = \sprintf(
131 /* translators: 1: follower count at start, 2: follower count at end */
132 \__( 'From <strong>%1$s</strong> to <strong>%2$s</strong> followers', 'activitypub' ),
133 \number_format_i18n( $summary['followers_start'] ?? 0 ),
134 \number_format_i18n( $summary['followers_end'] ?? 0 )
135 );
136 }
137
138 // Build supporter text.
139 $supporter_text = '';
140 if ( ! empty( $summary['top_multiplicator'] ) ) {
141 $supporter_text = \sprintf(
142 /* translators: 1: supporter URL, 2: supporter name, 3: boost count */
143 \__( '<strong><a href="%1$s">%2$s</a></strong> with %3$s boosts', 'activitypub' ),
144 \esc_url( $summary['top_multiplicator']['url'] ),
145 \esc_html( $summary['top_multiplicator']['name'] ),
146 \number_format_i18n( $summary['top_multiplicator']['count'] )
147 );
148 }
149
150 $args = \array_merge(
151 $summary,
152 array(
153 /* translators: %d: Year */
154 'title' => \sprintf( \__( 'Your %d Fediverse Year in Review', 'activitypub' ), $year ),
155 /* translators: %d: Year */
156 'intro' => \sprintf( \__( "Here's a look back at your %d activity on the Fediverse.", 'activitypub' ), $year ),
157 'closing' => \__( 'Thanks for being part of the Fediverse! Here\'s to another great year.', 'activitypub' ),
158 'most_active_month_name' => $most_active_month_name,
159 'followers_text' => $followers_text,
160 'supporter_text' => $supporter_text,
161 'user_id' => $user_id,
162 )
163 );
164
165 $subject = \sprintf(
166 /* translators: 1: Blog name, 2: Year */
167 \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ),
168 \esc_html( \get_option( 'blogname' ) ),
169 $year
170 );
171
172 // Build plain text alternative.
173 /* translators: %d: Year */
174 $alt_body = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year );
175
176 if ( ! empty( $args['posts_count'] ) ) {
177 /* translators: %d: Number of posts */
178 $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] );
179 }
180
181 if ( ! empty( $args['followers_net_change'] ) ) {
182 /* translators: %d: Net follower change */
183 $alt_body .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] );
184 }
185
186 if ( ! empty( $most_active_month_name ) ) {
187 /* translators: %s: Month name */
188 $alt_body .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $most_active_month_name );
189 }
190
191 Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body );
192 }
193
194 /**
195 * Send the monthly stats report email.
196 *
197 * @param int $user_id The user ID.
198 * @param int $year The year.
199 * @param int $month The month (1-12).
200 * @param bool $force Whether to bypass user preference checks.
201 */
202 public static function send_monthly_email( $user_id, $year, $month, $force = false ) {
203 $option_name = Statistics_Collector::get_monthly_option_name( $user_id, $year, $month );
204 $stats = \get_option( $option_name, array() );
205
206 if ( empty( $stats ) ) {
207 return;
208 }
209
210 if ( ! $force && ! self::should_send_report( $user_id, $stats, 'activitypub_mailer_monthly_report', '1' ) ) {
211 return;
212 }
213
214 // Atomic claim: add_option only succeeds if the row doesn't yet exist, so this
215 // is race-safe across concurrent cron workers and re-entrant invocations.
216 // When $force is true, we still record the marker so a later non-forced cron run
217 // won't send another copy for the same period.
218 $email_sent_option = self::get_email_sent_option_name( $user_id, $year, $month );
219 if ( ! \add_option( $email_sent_option, \time(), '', false ) ) {
220 if ( ! $force ) {
221 return;
222 }
223
224 \update_option( $email_sent_option, \time(), false );
225 }
226
227 $month_name = \date_i18n( 'F Y', \strtotime( \sprintf( '%d-%02d-01', $year, $month ) ) );
228
229 // Build follower text.
230 $followers_text = '';
231 if ( ! empty( $stats['followers_total'] ) ) {
232 $followers_text = \sprintf(
233 /* translators: %s: total follower count */
234 \__( 'You now have <strong>%s</strong> followers', 'activitypub' ),
235 \number_format_i18n( $stats['followers_total'] )
236 );
237 }
238
239 // Build supporter text.
240 $supporter_text = '';
241 if ( ! empty( $stats['top_multiplicator'] ) ) {
242 $supporter_text = \sprintf(
243 /* translators: 1: supporter URL, 2: supporter name, 3: boost count */
244 \__( '<strong><a href="%1$s">%2$s</a></strong> with %3$s boosts', 'activitypub' ),
245 \esc_url( $stats['top_multiplicator']['url'] ),
246 \esc_html( $stats['top_multiplicator']['name'] ),
247 \number_format_i18n( $stats['top_multiplicator']['count'] )
248 );
249 }
250
251 $args = \array_merge(
252 $stats,
253 array(
254 /* translators: %s: Month and year, e.g. "March 2025" */
255 'title' => \sprintf( \__( 'Your Fediverse Stats for %s', 'activitypub' ), $month_name ),
256 /* translators: %s: Month and year, e.g. "March 2025" */
257 'intro' => \sprintf( \__( "Here's how your content performed on the Fediverse in %s.", 'activitypub' ), $month_name ),
258 'closing' => \__( 'Keep sharing great content on the Fediverse!', 'activitypub' ),
259 'followers_text' => $followers_text,
260 'supporter_text' => $supporter_text,
261 'user_id' => $user_id,
262 )
263 );
264
265 $subject = \sprintf(
266 /* translators: 1: Blog name, 2: Month and year */
267 \__( '[%1$s] Your Fediverse Stats for %2$s', 'activitypub' ),
268 \esc_html( \get_option( 'blogname' ) ),
269 $month_name
270 );
271
272 // Build plain text alternative.
273 /* translators: %s: Month and year */
274 $alt_body = \sprintf( \__( "Here's your Fediverse stats for %s:\n\n", 'activitypub' ), $month_name );
275
276 if ( ! empty( $stats['posts_count'] ) ) {
277 /* translators: %d: Number of posts */
278 $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $stats['posts_count'] );
279 }
280
281 if ( ! empty( $stats['followers_count'] ) ) {
282 /* translators: %d: New follower count */
283 $alt_body .= \sprintf( \__( "New followers: %+d\n", 'activitypub' ), $stats['followers_count'] );
284 }
285
286 Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body );
287 }
288
289 /**
290 * Build the option name used to record that a stats email has been sent for a given period.
291 *
292 * The presence of this option is the idempotency signal: a row exists once an email
293 * has been delivered (or claimed for delivery) for the given user and period.
294 *
295 * @param int $user_id The user ID.
296 * @param int $year The year.
297 * @param int|null $month The month (1-12), or null for the annual report.
298 *
299 * @return string The option name. Truncated to fit MySQL's 191-character key.
300 */
301 private static function get_email_sent_option_name( $user_id, $year, $month = null ) {
302 $suffix = null === $month ? \sprintf( '%d_annual', $year ) : \sprintf( '%d_%d', $year, $month );
303 return \substr( \sprintf( 'activitypub_stats_emailed_%d_%s', $user_id, $suffix ), 0, 191 );
304 }
305
306 /**
307 * Check whether a stats report should be sent.
308 *
309 * Verifies user preference and that there is meaningful activity.
310 *
311 * @param int $user_id The user ID.
312 * @param array $stats The stats data.
313 * @param string $option_name The preference option name (same for blog and user).
314 * @param string $fallback The fallback value for the blog option.
315 *
316 * @return bool True if the report should be sent.
317 */
318 private static function should_send_report( $user_id, $stats, $option_name, $fallback = '1' ) {
319 if ( empty( $stats ) ) {
320 return false;
321 }
322
323 // Check user preference.
324 if ( $user_id > Actors::BLOG_USER_ID ) {
325 if ( ! \get_user_option( $option_name, $user_id ) ) {
326 return false;
327 }
328 } elseif ( '1' !== \get_option( $option_name, $fallback ) ) {
329 return false;
330 }
331
332 // Check that there is meaningful activity.
333 if ( ! empty( $stats['posts_count'] ) || ! empty( $stats['followers_count'] ) ) {
334 return true;
335 }
336
337 $comment_types = \array_keys( Statistics_Collector::get_comment_types_for_stats() );
338 foreach ( $comment_types as $type ) {
339 if ( ! empty( $stats[ $type . '_count' ] ) ) {
340 return true;
341 }
342 }
343
344 return false;
345 }
346 }
347