Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnsubscribeInactiveUsers
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 11
1482
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
132
 getSubscribers
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 isSubscriberInactive
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 isSubscriberInactiveOnSite
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 isSubscriberBlocked
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 removeSubscriber
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 triggerEchoNotification
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 listUsers
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 printInformationMessage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 logVerbose
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\TranslationNotifications;
5
6use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
7use MediaWiki\Extension\Notifications\Model\Event as EchoEvent;
8use MediaWiki\Language\RawMessage;
9use MediaWiki\Maintenance\Maintenance;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\User\UserIdentity;
12use MediaWiki\User\UserIdentityValue;
13use Wikimedia\Rdbms\IExpression;
14use Wikimedia\Rdbms\LikeValue;
15
16$env = getenv( 'MW_INSTALL_PATH' );
17$IP = $env !== false ? $env : __DIR__ . '/../../..';
18require_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 */
33class 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;
342require_once RUN_MAINTENANCE_IF_MAIN;