PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.0
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 8.3.0, at includes/scheduler/class-statistics.php

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