mediawiki-extensions-Echo/includes/controller/NotificationController.php
Kunal Mehta 5d2cde1022 Implement per-user notification blacklists
- Disabled by default, is a gated preference by wg variable
- User specifies blocks in Echo preferences
- Uses a TextArea with username separated by new lines as input
- Still allows notifications to come through on a user's talk page
- Cache the blacklist and whitelist

Requested at
<https://meta.wikimedia.org/wiki/2016_Community_Wishlist_Survey/Categories/Miscellaneous#Allow_users_to_restrict_who_can_send_them_notifications>.

Bug: T150419
Change-Id: Ibf548da4aa600bdc7848cba1947436e56ac48a4a
2017-05-24 16:09:04 -05:00

460 lines
14 KiB
PHP

<?php
/**
* This class represents the controller for notifications
*/
class EchoNotificationController {
/**
* Echo maximum number of users to cache
*
* @var int $maxRecipientCacheSize
*/
static protected $maxRecipientCacheSize = 200;
/**
* Echo event agent per user blacklist
*
* @var MapCacheLRU
*/
static protected $blacklistByUser;
/**
* Echo event agent per wiki blacklist
*
* @var EchoContainmentList|null
*/
static protected $wikiBlacklist;
/**
* Echo event agent per user whitelist, this overwrites $blacklistByUser
*
* @var MapCacheLRU
*/
static protected $whitelistByUser;
/**
* Returns the count passed in, or MWEchoNotifUser::MAX_BADGE_COUNT + 1,
* whichever is less.
*
* @param int $count
* @return int Notification count, with ceiling applied
*/
public static function getCappedNotificationCount( $count ) {
if ( $count <= MWEchoNotifUser::MAX_BADGE_COUNT ) {
return $count;
} else {
return MWEchoNotifUser::MAX_BADGE_COUNT + 1;
}
}
/**
* Format the notification count as a string. This should only be used for an
* isolated string count, e.g. as displayed in personal tools or returned by the API.
*
* If using it in sentence context, pass the value from getCappedNotificationCount
* into a message and use PLURAL. Example: notification-bundle-header-page-linked
*
* @param int count Notification count
* @return string Formatted count, after applying cap then formatting to string
*/
public static function formatNotificationCount( $count ) {
$cappedCount = self::getCappedNotificationCount( $count );
return wfMessage( 'echo-badge-count' )->numParams( $cappedCount )->text();
}
/**
* Processes notifications for a newly-created EchoEvent
*
* @param EchoEvent $event
* @param boolean $defer Defer to job queue or not
*/
public static function notify( $event, $defer = true ) {
// Defer to job queue if defer to job queue is requested and
// this event should use job queue
if ( $defer && $event->getUseJobQueue() ) {
// defer job insertion till end of request when all primary db transactions
// have been committed
DeferredUpdates::addCallableUpdate( function () use ( $event ) {
// can't use self::, php 5.3 doesn't inherit class scope
EchoNotificationController::enqueueEvent( $event );
} );
return;
}
// Check if the event object has valid event type. Events with invalid
// event types left in the job queue should not be processed
if ( !$event->isEnabledEvent() ) {
return;
}
$type = $event->getType();
$notifyTypes = self::getEventNotifyTypes( $type );
$userIds = [];
$userIdsCount = 0;
foreach ( self::getUsersToNotifyForEvent( $event ) as $user ) {
$userIds[$user->getId()] = $user->getId();
$userNotifyTypes = $notifyTypes;
// Respect the enotifminoredits preference
// @todo should this be checked somewhere else?
if ( !$user->getOption( 'enotifminoredits' ) ) {
$extra = $event->getExtra();
if ( !empty( $extra['revid'] ) ) {
$rev = Revision::newFromID( $extra['revid'], Revision::READ_LATEST );
if ( $rev->isMinor() ) {
$notifyTypes = array_diff( $notifyTypes, [ 'email' ] );
}
}
}
Hooks::run( 'EchoGetNotificationTypes', [ $user, $event, &$userNotifyTypes ] );
// types such as web, email, etc
foreach ( $userNotifyTypes as $type ) {
self::doNotification( $event, $user, $type );
}
$userIdsCount++;
// Process 1000 users per NotificationDeleteJob
if ( $userIdsCount > 1000 ) {
self::enqueueDeleteJob( $userIds, $event );
$userIds = [];
$userIdsCount = 0;
}
}
// process the userIds left in the array
if ( $userIds ) {
self::enqueueDeleteJob( $userIds, $event );
}
}
/**
* Schedule a job to check and delete older notifications
*
* @param int $userIds
* @param EchoEvent $event
*/
public static function enqueueDeleteJob( array $userIds, EchoEvent $event ) {
// Do nothing if there is no user
if ( !$userIds ) {
return;
}
$job = new EchoNotificationDeleteJob(
$event->getTitle() ?: Title::newMainPage(),
[
'userIds' => $userIds
]
);
JobQueueGroup::singleton()->push( $job );
}
/**
* Get the notify types for this event, eg, web/email
*
* @param string $eventType Event type
* @return string[] List of notify types that apply for
* this event type
*/
public static function getEventNotifyTypes( $eventType ) {
global $wgDefaultNotifyTypeAvailability,
$wgEchoNotifications;
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$category = $attributeManager->getNotificationCategory( $eventType );
// If the category is displayed in preferences, we should go by that, rather
// than overrides that are inconsistent with what the user saw in preferences.
$isTypeSpecificConsidered = !$attributeManager->isCategoryDisplayedInPreferences(
$category
);
$notifyTypes = $wgDefaultNotifyTypeAvailability;
if ( $isTypeSpecificConsidered && isset( $wgEchoNotifications[$eventType]['notify-type-availability'] ) ) {
$notifyTypes = array_merge(
$notifyTypes,
$wgEchoNotifications[$eventType]['notify-type-availability']
);
}
// Category settings for availability are considered in EchoNotifier
return array_keys( array_filter( $notifyTypes ) );
}
/**
* Push $event onto the mediawiki job queue
*
* @param EchoEvent $event
*/
public static function enqueueEvent( EchoEvent $event ) {
$job = new EchoNotificationJob(
$event->getTitle() ?: Title::newMainPage(),
[
'event' => $event,
'masterPos' => MWEchoDbFactory::newFromDefault()
->getMasterPosition(),
]
);
JobQueueGroup::singleton()->push( $job );
}
/**
* Implements blacklist per active wiki expected to be initialized
* from InitializeSettings.php
*
* @param EchoEvent $event The event to test for exclusion
* @param User $user recipient of the notification for per-user blacklists
* @return boolean True when the event agent is blacklisted
*/
public static function isBlacklistedByUser( EchoEvent $event, User $user ) {
global $wgEchoAgentBlacklist, $wgEchoPerUserBlacklist;
$clusterCache = ObjectCache::getLocalClusterInstance();
if ( !$event->getAgent() ) {
return false;
}
// Ensure we have a list of blacklists
if ( self::$blacklistByUser === null ) {
self::$blacklistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
}
// Ensure we have a blacklist for the user
if ( !self::$blacklistByUser->has( $user->getId() ) ) {
$blacklist = new EchoContainmentSet( $user );
// Add the config setting
$blacklist->addArray( $wgEchoAgentBlacklist );
// Add wiki-wide blacklist
$wikiBlacklist = self::getWikiBlacklist();
if ( $wikiBlacklist !== null ) {
$blacklist->add( $wikiBlacklist );
}
// Add to blacklist from user preference
if ( $wgEchoPerUserBlacklist ) {
$blacklist->addFromUserOption( 'echo-notifications-blacklist' );
}
// Add user's blacklist to dictionary if user wasn't already there
self::$blacklistByUser->set( $user->getId(), $blacklist );
} else {
// Just get the user's blacklist if it's already there
$blacklist = self::$blacklistByUser->get( $user->getId() );
}
return $blacklist->contains( $event->getAgent()->getName() );
}
/**
* @return EchoContainmentList|null
*/
protected static function getWikiBlacklist() {
$clusterCache = ObjectCache::getLocalClusterInstance();
global $wgEchoOnWikiBlacklist;
if ( !$wgEchoOnWikiBlacklist ) {
return null;
}
if ( self::$wikiBlacklist === null ) {
self::$wikiBlacklist = new EchoCachedList(
$clusterCache,
$clusterCache->makeKey( "echo_on_wiki_blacklist" ),
new EchoOnWikiList( NS_MEDIAWIKI, $wgEchoOnWikiBlacklist )
);
}
return self::$wikiBlacklist;
}
/**
* Implements per-user whitelist sourced from a user wiki page
*
* @param EchoEvent $event The event to test for inclusion in whitelist
* @param User $user The user that owns the whitelist
* @return boolean True when the event agent is in the user whitelist
*/
public static function isWhitelistedByUser( EchoEvent $event, User $user ) {
$clusterCache = ObjectCache::getLocalClusterInstance();
global $wgEchoPerUserWhitelistFormat;
if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
return false;
}
$userId = $user->getID();
if ( $userId === 0 ) {
return false; // anonymous user
}
// Ensure we have a list of whitelists
if ( self::$whitelistByUser === null ) {
self::$whitelistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
}
// Ensure we have a whitelist for the user
if ( !self::$whitelistByUser->has( $userId ) ) {
$whitelist = new EchoContainmentSet( $user );
self::$whitelistByUser->set( $userId, $whitelist );
$whitelist->addOnWiki(
NS_USER,
sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
$clusterCache,
$clusterCache->makeKey( "echo_on_wiki_whitelist_" . $userId )
);
} else {
// Just get the user's whitelist
$whitelist = self::$whitelistByUser->get( $userId );
}
return $whitelist->contains( $event->getAgent()->getName() );
}
/**
* Processes a single notification for an EchoEvent
*
* @param EchoEvent $event
* @param User $user The user to be notified.
* @param string $type The type of notification delivery to process, e.g. 'email'.
* @throws MWException
*/
public static function doNotification( $event, $user, $type ) {
global $wgEchoNotifiers;
if ( !isset( $wgEchoNotifiers[$type] ) ) {
throw new MWException( "Invalid notification type $type" );
}
// Don't send any notifications to anonymous users
if ( $user->isAnon() ) {
throw new MWException( "Cannot notify anonymous user: {$user->getName()}" );
}
call_user_func_array( $wgEchoNotifiers[$type], [ $user, $event ] );
}
/**
* Returns an array each element of which is the result of a
* user-locator|user-filters attached to the event type.
*
* @param EchoEvent $event
* @param string $locator Either EchoAttributeManager::ATTR_LOCATORS or EchoAttributeManager::ATTR_FILTERS
* @return array
*/
public static function evaluateUserCallable( EchoEvent $event, $locator = EchoAttributeManager::ATTR_LOCATORS ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$type = $event->getType();
$result = [];
foreach ( $attributeManager->getUserCallable( $type, $locator ) as $callable ) {
// locator options can be set per-event by using an array with
// name as first parameter.
if ( is_array( $callable ) ) {
$options = $callable;
$spliced = array_splice( $options, 0, 1, [ $event ] );
$callable = reset( $spliced );
} else {
$options = [ $event ];
}
if ( is_callable( $callable ) ) {
$result[] = call_user_func_array( $callable, $options );
} else {
wfDebugLog( __CLASS__, __FUNCTION__ . ": Invalid $locator returned for $type" );
}
}
return $result;
}
/**
* Retrieves an array of User objects to be notified for an EchoEvent.
*
* @param EchoEvent $event
* @return Iterator values are User objects
*/
public static function getUsersToNotifyForEvent( EchoEvent $event ) {
$notify = new EchoFilteredSequentialIterator;
foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_LOCATORS ) as $users ) {
$notify->add( $users );
}
// Hook for injecting more users.
// @deprecated
$users = [];
Hooks::run( 'EchoGetDefaultNotifiedUsers', [ $event, &$users ] );
if ( $users ) {
$notify->add( $users );
}
// Exclude certain users
foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_FILTERS ) as $users ) {
// the result of the callback can be both an iterator or array
$users = is_array( $users ) ? $users : iterator_to_array( $users );
$notify->addFilter( function ( User $user ) use ( $users ) {
// we need to check if $user is in $users, but they're not
// guaranteed to be the same object, so I'll compare ids.
$userId = $user->getId();
$userIds = array_map( function ( User $user ) {
return $user->getId();
}, $users );
return !in_array( $userId, $userIds );
} );
}
// Filter non-User, anon and duplicate users
$seen = [];
$notify->addFilter( function ( $user ) use ( &$seen ) {
if ( !$user instanceof User ) {
wfDebugLog( __METHOD__, 'Expected all User instances, received:' .
( is_object( $user ) ? get_class( $user ) : gettype( $user ) )
);
return false;
}
if ( $user->isAnon() || isset( $seen[$user->getId()] ) ) {
return false;
}
$seen[$user->getId()] = true;
return true;
} );
// Don't notify the person who initiated the event unless the event extra says to do so
$extra = $event->getExtra();
if ( ( !isset( $extra['notifyAgent'] ) || !$extra['notifyAgent'] ) && $event->getAgent() ) {
$agentId = $event->getAgent()->getId();
$notify->addFilter( function ( $user ) use ( $agentId ) {
return $user->getId() != $agentId;
} );
}
// Apply blacklists and whitelists.
$notify->addFilter( function ( $user ) use ( $event ) {
if ( self::isBlacklistedByUser( $event, $user ) && $event->getTitle()->getNamespace() !== NS_USER_TALK ) {
return self::isWhitelistedByUser( $event, $user );
}
return true;
} );
return $notify->getIterator();
}
/**
* INTERNAL. Must be public to be callable by the php error handling methods.
*
* Converts E_RECOVERABLE_ERROR, such as passing null to a method expecting
* a non-null object, into exceptions.
*/
public static function formatterErrorHandler( $errno, $errstr, $errfile, $errline ) {
if ( $errno !== E_RECOVERABLE_ERROR ) {
return false;
}
throw new EchoCatchableFatalErrorException( $errno, $errstr, $errfile, $errline );
}
}