Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 190 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
UnsubscribeInactiveUsers | |
0.00% |
0 / 185 |
|
0.00% |
0 / 11 |
1482 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
132 | |||
getSubscribers | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
2 | |||
isSubscriberInactive | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
20 | |||
isSubscriberInactiveOnSite | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
isSubscriberBlocked | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
removeSubscriber | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
triggerEchoNotification | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
listUsers | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
printInformationMessage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
logVerbose | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\TranslationNotifications; |
5 | |
6 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
7 | use MediaWiki\Extension\Notifications\Model\Event as EchoEvent; |
8 | use MediaWiki\Language\RawMessage; |
9 | use MediaWiki\Maintenance\Maintenance; |
10 | use MediaWiki\MediaWikiServices; |
11 | use MediaWiki\User\UserIdentity; |
12 | use MediaWiki\User\UserIdentityValue; |
13 | use Wikimedia\Rdbms\IExpression; |
14 | use Wikimedia\Rdbms\LikeValue; |
15 | |
16 | $env = getenv( 'MW_INSTALL_PATH' ); |
17 | $IP = $env !== false ? $env : __DIR__ . '/../../..'; |
18 | require_once "$IP/maintenance/Maintenance.php"; |
19 | |
20 | /** |
21 | * This script unsubscribes translators who've been inactive or blocked for a certain duration of time. |
22 | * A user is considered blocked if all the following conditions are met: |
23 | * ** The block is site wide |
24 | * ** Has the user been blocked for 31 days |
25 | * ** Is the user permanently blocked OR will the user be blocked even after a certain duration |
26 | * Inactivity for a user is checked across wikis that they have accounts on. |
27 | * |
28 | * @author Eugene Wang'ombe |
29 | * @author Abijeet Patro |
30 | * @copyright Copyright © 2023 |
31 | * @license GPL-2.0-or-later |
32 | */ |
33 | class UnsubscribeInactiveUsers extends Maintenance { |
34 | private const ACTIVITY_CHECKS = [ |
35 | 'archive' => 'ar', |
36 | 'image' => 'img', |
37 | 'oldimage' => 'oi', |
38 | 'filearchive' => 'fa', |
39 | 'revision' => 'rev' |
40 | ]; |
41 | |
42 | public function __construct() { |
43 | parent::__construct(); |
44 | $this->addOption( |
45 | 'days', |
46 | 'Number of days without activity for a translator to be considered inactive.', |
47 | true, |
48 | true |
49 | ); |
50 | $this->addOption( |
51 | 'really', |
52 | 'Actually cancel subscriptions.' |
53 | ); |
54 | $this->addOption( |
55 | 'verbose', |
56 | 'Show verbose output' |
57 | ); |
58 | $this->addDescription( |
59 | "This script unsubscribes translators who've been inactive or blocked for a certain duration of time." |
60 | ); |
61 | |
62 | $this->requireExtension( 'TranslationNotifications' ); |
63 | $this->requireExtension( 'Echo' ); |
64 | $this->requireExtension( 'CentralAuth' ); |
65 | } |
66 | |
67 | public function execute() { |
68 | $inactiveSubscribers = []; |
69 | $blockedSubscribers = []; |
70 | $userOptionManager = $this->getServiceContainer()->getUserOptionsManager(); |
71 | |
72 | $inactiveDays = (int)$this->getOption( 'days' ); |
73 | $isDryRun = !$this->hasOption( 'really' ); |
74 | |
75 | // Fetch all the translation notification subscribers |
76 | [ $subscriberCount, $subscribers ] = $this->getSubscribers(); |
77 | $this->printInformationMessage( "Found $1 subscriber{{PLURAL:$1||s}}.\n", $subscriberCount ); |
78 | |
79 | $inactiveTs = wfTimestamp( TS_MW, time() - $inactiveDays * 24 * 3600 ); |
80 | $inactiveTsToPrint = wfTimestamp( TS_ISO_8601, $inactiveTs ); |
81 | $this->output( "Checking for subscribers who are blocked or inactive since $inactiveTsToPrint\n" ); |
82 | $currentSubscriber = 0; |
83 | |
84 | $this->output( "\n" ); |
85 | |
86 | // Identify inactive or blocked subscribers |
87 | foreach ( $subscribers as $subscribedUser ) { |
88 | ++$currentSubscriber; |
89 | $this->logVerbose( "... $currentSubscriber / $subscriberCount\n" ); |
90 | |
91 | // Check if the subscription has been updated after inactivity cutoff time. It means that the user has |
92 | // made some changes to their translator signup configuration recently. |
93 | $lastActivityTs = $userOptionManager->getOption( |
94 | $subscribedUser, 'translationnotifications-lastactivity' |
95 | ); |
96 | |
97 | // Last activity was not tracked originally, so it might be missing. |
98 | if ( $lastActivityTs && $lastActivityTs > $inactiveTs ) { |
99 | $this->logVerbose( "{$subscribedUser->getName()} was recently active\n" ); |
100 | continue; |
101 | } |
102 | |
103 | if ( $this->isSubscriberBlocked( $subscribedUser, $inactiveDays ) ) { |
104 | $blockedSubscribers[] = $subscribedUser; |
105 | } elseif ( $this->isSubscriberInactive( $subscribedUser, $inactiveTs ) ) { |
106 | $inactiveSubscribers[] = $subscribedUser; |
107 | } |
108 | $this->logVerbose( "\n" ); |
109 | } |
110 | |
111 | $this->printInformationMessage( |
112 | "Found $1 inactive subscriber{{PLURAL:$1||s}}.\n", count( $inactiveSubscribers ) |
113 | ); |
114 | $this->listUsers( $inactiveSubscribers ); |
115 | $this->output( "\n" ); |
116 | $this->printInformationMessage( |
117 | "Found $1 blocked subscriber{{PLURAL:$1||s}}.\n", count( $blockedSubscribers ) |
118 | ); |
119 | $this->listUsers( $blockedSubscribers ); |
120 | $this->output( "\n" ); |
121 | |
122 | if ( $isDryRun ) { |
123 | $this->output( "Running in dry-run mode. Exiting...\n" ); |
124 | return true; |
125 | } |
126 | |
127 | $subscribersToRemove = array_merge( $inactiveSubscribers, $blockedSubscribers ); |
128 | |
129 | if ( $subscribersToRemove ) { |
130 | $this->printInformationMessage( |
131 | "Removing subscriber{{PLURAL:$1||s}}...\n", count( $subscribersToRemove ) |
132 | ); |
133 | |
134 | $failedRecords = []; |
135 | // Unsubscribe subscribers |
136 | foreach ( $subscribersToRemove as $subscriber ) { |
137 | if ( !$this->removeSubscriber( $subscriber ) ) { |
138 | $failedRecords[] = $subscriber; |
139 | } |
140 | } |
141 | |
142 | if ( $failedRecords ) { |
143 | $this->printInformationMessage( |
144 | "Failed to remove $1 subscriber{{PLURAL:$1||s}}\n", count( $failedRecords ) |
145 | ); |
146 | $this->listUsers( $failedRecords ); |
147 | } |
148 | |
149 | $this->output( "Done\n" ); |
150 | } else { |
151 | $this->output( "No inactive or blocked subscribers found.\n" ); |
152 | } |
153 | |
154 | return true; |
155 | } |
156 | |
157 | private function getSubscribers(): array { |
158 | $mwServices = $this->getServiceContainer(); |
159 | $dbr = $mwServices->getDBLoadBalancer()->getConnection( DB_REPLICA ); |
160 | $queryBuilder = $mwServices->getUserIdentityLookup()->newSelectQueryBuilder(); |
161 | |
162 | $queryBuilder |
163 | ->join( 'user_properties', 'up', [ 'actor_user = up.up_user' ] ) |
164 | ->where( |
165 | $dbr->expr( |
166 | 'up.up_property', |
167 | IExpression::LIKE, |
168 | new LikeValue( 'translationnotifications-lang-', $dbr->anyString() ) |
169 | ) |
170 | ) |
171 | ->caller( __METHOD__ ) |
172 | ->distinct(); |
173 | |
174 | $countQueryBuilder = clone $queryBuilder; |
175 | return [ |
176 | // Not using fetchRowCount() as it returns incorrect value with DISTINCT. See: T333065 |
177 | count( |
178 | $countQueryBuilder |
179 | ->fields( 'actor_user' ) |
180 | ->fetchFieldValues() |
181 | ), |
182 | $queryBuilder->fetchUserIdentities() |
183 | ]; |
184 | } |
185 | |
186 | private function isSubscriberInactive( UserIdentity $subscriber, string $inactiveTs ): bool { |
187 | $centralUser = CentralAuthUser::getInstance( $subscriber ); |
188 | $attachedAccounts = $centralUser->queryAttached(); |
189 | $mwServices = $this->getServiceContainer(); |
190 | |
191 | if ( !$attachedAccounts ) { |
192 | $this->logVerbose( "No central account attached to user: {$subscriber->getName()}\n" ); |
193 | return false; |
194 | } else { |
195 | $this->logVerbose( count( $attachedAccounts ) . " accounts found for user: {$subscriber->getName()}\n" ); |
196 | } |
197 | |
198 | // Sort the accounts based on edit counts. More edits, more chances of the user being active on the wiki. |
199 | usort( $attachedAccounts, static function ( $accountA, $accountB ) { |
200 | return $accountB[ 'editCount' ] <=> $accountA[ 'editCount' ]; |
201 | } ); |
202 | |
203 | foreach ( $attachedAccounts as $accountInfo ) { |
204 | $isUserInactive = $this->isSubscriberInactiveOnSite( |
205 | $mwServices, |
206 | new UserIdentityValue( (int)$accountInfo[ 'id' ], $accountInfo['name'], $accountInfo['wiki'] ), |
207 | $inactiveTs |
208 | ); |
209 | if ( !$isUserInactive ) { |
210 | $this->logVerbose( "{$subscriber->getName()} active on {$accountInfo[ 'wiki' ]}\n" ); |
211 | return false; |
212 | } |
213 | } |
214 | |
215 | return true; |
216 | } |
217 | |
218 | private function isSubscriberInactiveOnSite( |
219 | MediaWikiServices $mwServices, |
220 | UserIdentity $user, |
221 | string $inactiveTs |
222 | ): bool { |
223 | $siteId = $user->getWikiId(); |
224 | $dbr = $mwServices->getDBLoadBalancerFactory() |
225 | ->getMainLB( $siteId ) |
226 | ->getConnection( DB_REPLICA, [], $siteId ); |
227 | $actorStore = $mwServices->getActorStoreFactory()->getActorStore( $siteId ); |
228 | |
229 | $actorId = $actorStore->findActorId( $user, $dbr ); |
230 | if ( $actorId === null ) { |
231 | return true; |
232 | } |
233 | |
234 | // Check if the user has made any contributions |
235 | foreach ( self::ACTIVITY_CHECKS as $table => $prefix ) { |
236 | $prefixedActorColumn = "{$prefix}_actor"; |
237 | $prefixedTimestampColumn = "{$prefix}_timestamp"; |
238 | |
239 | $contributionCount = $dbr->newSelectQueryBuilder() |
240 | ->from( $table ) |
241 | ->where( [ $prefixedActorColumn => $actorId ] ) |
242 | ->andWhere( $dbr->expr( $prefixedTimestampColumn, '>', $inactiveTs ) ) |
243 | ->limit( 1 ) |
244 | ->caller( __METHOD__ ) |
245 | ->fetchRowCount(); |
246 | |
247 | if ( $contributionCount ) { |
248 | return false; |
249 | } |
250 | } |
251 | |
252 | return true; |
253 | } |
254 | |
255 | private function isSubscriberBlocked( UserIdentity $subscriber, int $inactiveDays ): bool { |
256 | $mwServices = $this->getServiceContainer(); |
257 | $blockManager = $mwServices->getBlockManager(); |
258 | |
259 | $userBlock = $blockManager->getBlock( $subscriber, null ); |
260 | if ( !$userBlock ) { |
261 | return false; |
262 | } |
263 | |
264 | // Check the following: |
265 | // 1. Is the block site-wide |
266 | // 2. Has the user been blocked for 31 days |
267 | // 3. Is the user permanently blocked OR will the user be blocked even after a certain duration |
268 | return $userBlock->isSitewide() && |
269 | $userBlock->getTimestamp() < wfTimestamp( TS_MW, time() - ( 31 * 24 * 60 ) ) && |
270 | ( |
271 | $userBlock->getExpiry() === 'infinity' || |
272 | $userBlock->getExpiry() > wfTimestamp( TS_MW, time() + $inactiveDays * 24 * 60 ) |
273 | ); |
274 | } |
275 | |
276 | private function removeSubscriber( UserIdentity $subscriber ): bool { |
277 | $userOptionsManager = $this->getServiceContainer()->getUserOptionsManager(); |
278 | $subscriberOptions = $userOptionsManager->loadUserOptions( $subscriber ); |
279 | |
280 | $optionsUpdated = []; |
281 | foreach ( array_keys( $subscriberOptions ) as $optionKey ) { |
282 | if ( str_starts_with( $optionKey, 'translationnotifications-' ) ) { |
283 | $userOptionsManager->setOption( $subscriber, $optionKey, null ); |
284 | $optionsUpdated[] = $optionKey; |
285 | } |
286 | } |
287 | |
288 | if ( $optionsUpdated ) { |
289 | $event = $this->triggerEchoNotification( $subscriber->getId() ); |
290 | if ( $event === false ) { |
291 | // If sending of Echo notification fails, do not remove the subscriber |
292 | $userOptionsManager->clearUserOptionsCache( $subscriber ); |
293 | return false; |
294 | } else { |
295 | $userOptionsManager->saveOptions( $subscriber ); |
296 | } |
297 | } |
298 | |
299 | return true; |
300 | } |
301 | |
302 | /** |
303 | * @param int $subscriberId |
304 | * @return bool|EchoEvent |
305 | */ |
306 | private function triggerEchoNotification( int $subscriberId ) { |
307 | return EchoEvent::create( [ |
308 | 'type' => 'translationnotifications-unsubscribed', |
309 | 'extra' => [ |
310 | 'userId' => $subscriberId |
311 | ] |
312 | ] ); |
313 | } |
314 | |
315 | private function listUsers( array $subscribers ): void { |
316 | if ( $subscribers ) { |
317 | $this->output( "\n" ); |
318 | } |
319 | $count = 1; |
320 | foreach ( $subscribers as $subscriber ) { |
321 | $this->output( " $count. Id: {$subscriber->getId()}; Name: {$subscriber->getName()} \n" ); |
322 | $count++; |
323 | } |
324 | } |
325 | |
326 | private function printInformationMessage( string $message, int ...$messageArgs ): void { |
327 | $msg = ( new RawMessage( $message ) ) |
328 | ->numParams( $messageArgs ) |
329 | ->inLanguage( 'en' ) |
330 | ->text(); |
331 | $this->output( $msg ); |
332 | } |
333 | |
334 | private function logVerbose( string $log ): void { |
335 | if ( $this->hasOption( 'verbose' ) ) { |
336 | $this->output( $log ); |
337 | } |
338 | } |
339 | } |
340 | |
341 | $maintClass = UnsubscribeInactiveUsers::class; |
342 | require_once RUN_MAINTENANCE_IF_MAIN; |