mediawiki-extensions-Echo/includes/controller/NotificationController.php

413 lines
12 KiB
PHP
Raw Normal View History

<?php
/**
* This class represents the controller for notifications
*/
class EchoNotificationController {
/**
* Echo event agent per wiki blacklist
*
* @var string[]
*/
static protected $blacklist;
/**
* Echo event agent per user whitelist, this overwrites $blacklist
*
* @param string[]
*/
static protected $userWhitelist;
/**
* 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 = array();
$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, array( 'email' ) );
}
}
}
Hooks::run( 'EchoGetNotificationTypes', array( $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 = array();
$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(),
array(
'userIds' => $userIds
)
);
JobQueueGroup::singleton()->push( $job );
}
/**
BREAKING CHANGE: Change $wgEchoDefaultNotificationTypes to be logical Merge and deploy at the *same time* as: * BounceHandler - I3c669945080d8e1f67880bd8a31af7f88a70904d * mediawiki-config - I13817c139967ed9e230cfb0c87c5de66da793c96 Despite claiming to be about categories, $wgEchoDefaultNotificationTypes was actually configuring both categories and types (which go inside categories). For example, 'thank-you-edit' is a type, but 'emailuser' is both a category and a type (when used as a category, this has special effects at Special:Preferences). Since types and categories can and sometimes do have the same names, this leaves no way to properly and clearly configure them. It also makes it difficult to document what is going on (as required by T132127). Split into three variables: $wgDefaultNotifyTypeAvailability - Applies unless overriden $wgNotifyTypeAvailabilityByCategory - By category; this can be and is displayed at Special:Preferences $wgNotifyTypeAvailabilityByNotificationType - By type; this cannot be displayed at Special:Preferences. To avoid confusing the user, we introduce a restriction (which was previously followed in practice, AFAICT) that types can only be overridden if the category is not displayed in preferences. Otherwise, it can look to the user like a category is on/off, but the types within might have the opposite state. Due to this configuration change, this is a breaking change, and needs coordinated deployments. This also lays the groundwork for T132127 Also change terminology to consistently use "notify type" for web/email. It was mixing between that and output format (which unfortunately sounds like the API format, e.g. 'model'). Bug: T132820 Bug: T132127 Change-Id: I09f39f5fc5f13f3253af9f7819bca81f1601da93
2016-04-19 02:54:15 +00:00
* 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
*/
BREAKING CHANGE: Change $wgEchoDefaultNotificationTypes to be logical Merge and deploy at the *same time* as: * BounceHandler - I3c669945080d8e1f67880bd8a31af7f88a70904d * mediawiki-config - I13817c139967ed9e230cfb0c87c5de66da793c96 Despite claiming to be about categories, $wgEchoDefaultNotificationTypes was actually configuring both categories and types (which go inside categories). For example, 'thank-you-edit' is a type, but 'emailuser' is both a category and a type (when used as a category, this has special effects at Special:Preferences). Since types and categories can and sometimes do have the same names, this leaves no way to properly and clearly configure them. It also makes it difficult to document what is going on (as required by T132127). Split into three variables: $wgDefaultNotifyTypeAvailability - Applies unless overriden $wgNotifyTypeAvailabilityByCategory - By category; this can be and is displayed at Special:Preferences $wgNotifyTypeAvailabilityByNotificationType - By type; this cannot be displayed at Special:Preferences. To avoid confusing the user, we introduce a restriction (which was previously followed in practice, AFAICT) that types can only be overridden if the category is not displayed in preferences. Otherwise, it can look to the user like a category is on/off, but the types within might have the opposite state. Due to this configuration change, this is a breaking change, and needs coordinated deployments. This also lays the groundwork for T132127 Also change terminology to consistently use "notify type" for web/email. It was mixing between that and output format (which unfortunately sounds like the API format, e.g. 'model'). Bug: T132820 Bug: T132127 Change-Id: I09f39f5fc5f13f3253af9f7819bca81f1601da93
2016-04-19 02:54:15 +00:00
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;
BREAKING CHANGE: Change $wgEchoDefaultNotificationTypes to be logical Merge and deploy at the *same time* as: * BounceHandler - I3c669945080d8e1f67880bd8a31af7f88a70904d * mediawiki-config - I13817c139967ed9e230cfb0c87c5de66da793c96 Despite claiming to be about categories, $wgEchoDefaultNotificationTypes was actually configuring both categories and types (which go inside categories). For example, 'thank-you-edit' is a type, but 'emailuser' is both a category and a type (when used as a category, this has special effects at Special:Preferences). Since types and categories can and sometimes do have the same names, this leaves no way to properly and clearly configure them. It also makes it difficult to document what is going on (as required by T132127). Split into three variables: $wgDefaultNotifyTypeAvailability - Applies unless overriden $wgNotifyTypeAvailabilityByCategory - By category; this can be and is displayed at Special:Preferences $wgNotifyTypeAvailabilityByNotificationType - By type; this cannot be displayed at Special:Preferences. To avoid confusing the user, we introduce a restriction (which was previously followed in practice, AFAICT) that types can only be overridden if the category is not displayed in preferences. Otherwise, it can look to the user like a category is on/off, but the types within might have the opposite state. Due to this configuration change, this is a breaking change, and needs coordinated deployments. This also lays the groundwork for T132127 Also change terminology to consistently use "notify type" for web/email. It was mixing between that and output format (which unfortunately sounds like the API format, e.g. 'model'). Bug: T132820 Bug: T132127 Change-Id: I09f39f5fc5f13f3253af9f7819bca81f1601da93
2016-04-19 02:54:15 +00:00
if ( $isTypeSpecificConsidered && isset( $wgEchoNotifications[$eventType]['notify-type-availability'] ) ) {
$notifyTypes = array_merge(
$notifyTypes,
BREAKING CHANGE: Change $wgEchoDefaultNotificationTypes to be logical Merge and deploy at the *same time* as: * BounceHandler - I3c669945080d8e1f67880bd8a31af7f88a70904d * mediawiki-config - I13817c139967ed9e230cfb0c87c5de66da793c96 Despite claiming to be about categories, $wgEchoDefaultNotificationTypes was actually configuring both categories and types (which go inside categories). For example, 'thank-you-edit' is a type, but 'emailuser' is both a category and a type (when used as a category, this has special effects at Special:Preferences). Since types and categories can and sometimes do have the same names, this leaves no way to properly and clearly configure them. It also makes it difficult to document what is going on (as required by T132127). Split into three variables: $wgDefaultNotifyTypeAvailability - Applies unless overriden $wgNotifyTypeAvailabilityByCategory - By category; this can be and is displayed at Special:Preferences $wgNotifyTypeAvailabilityByNotificationType - By type; this cannot be displayed at Special:Preferences. To avoid confusing the user, we introduce a restriction (which was previously followed in practice, AFAICT) that types can only be overridden if the category is not displayed in preferences. Otherwise, it can look to the user like a category is on/off, but the types within might have the opposite state. Due to this configuration change, this is a breaking change, and needs coordinated deployments. This also lays the groundwork for T132127 Also change terminology to consistently use "notify type" for web/email. It was mixing between that and output format (which unfortunately sounds like the API format, e.g. 'model'). Bug: T132820 Bug: T132127 Change-Id: I09f39f5fc5f13f3253af9f7819bca81f1601da93
2016-04-19 02:54:15 +00:00
$wgEchoNotifications[$eventType]['notify-type-availability']
);
}
BREAKING CHANGE: Change $wgEchoDefaultNotificationTypes to be logical Merge and deploy at the *same time* as: * BounceHandler - I3c669945080d8e1f67880bd8a31af7f88a70904d * mediawiki-config - I13817c139967ed9e230cfb0c87c5de66da793c96 Despite claiming to be about categories, $wgEchoDefaultNotificationTypes was actually configuring both categories and types (which go inside categories). For example, 'thank-you-edit' is a type, but 'emailuser' is both a category and a type (when used as a category, this has special effects at Special:Preferences). Since types and categories can and sometimes do have the same names, this leaves no way to properly and clearly configure them. It also makes it difficult to document what is going on (as required by T132127). Split into three variables: $wgDefaultNotifyTypeAvailability - Applies unless overriden $wgNotifyTypeAvailabilityByCategory - By category; this can be and is displayed at Special:Preferences $wgNotifyTypeAvailabilityByNotificationType - By type; this cannot be displayed at Special:Preferences. To avoid confusing the user, we introduce a restriction (which was previously followed in practice, AFAICT) that types can only be overridden if the category is not displayed in preferences. Otherwise, it can look to the user like a category is on/off, but the types within might have the opposite state. Due to this configuration change, this is a breaking change, and needs coordinated deployments. This also lays the groundwork for T132127 Also change terminology to consistently use "notify type" for web/email. It was mixing between that and output format (which unfortunately sounds like the API format, e.g. 'model'). Bug: T132820 Bug: T132127 Change-Id: I09f39f5fc5f13f3253af9f7819bca81f1601da93
2016-04-19 02:54:15 +00:00
// 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(),
array(
'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 via global blacklist
* @return boolean True when the event agent is in the global blacklist
*/
protected static function isBlacklisted( EchoEvent $event ) {
if ( !$event->getAgent() ) {
return false;
}
if ( self::$blacklist === null ) {
global $wgEchoAgentBlacklist, $wgEchoOnWikiBlacklist;
self::$blacklist = new EchoContainmentSet;
self::$blacklist->addArray( $wgEchoAgentBlacklist );
if ( $wgEchoOnWikiBlacklist !== null ) {
self::$blacklist->addOnWiki(
NS_MEDIAWIKI,
$wgEchoOnWikiBlacklist,
ObjectCache::getLocalClusterInstance(),
wfMemcKey( "echo_on_wiki_blacklist" )
);
}
}
return self::$blacklist->contains( $event->getAgent()->getName() );
}
/**
* 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 ) {
global $wgEchoPerUserWhitelistFormat;
if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
return false;
}
$userId = $user->getID();
if ( $userId === 0 ) {
return false; // anonymous user
}
if ( !isset( self::$userWhitelist[$userId] ) ) {
self::$userWhitelist[$userId] = new EchoContainmentSet;
self::$userWhitelist[$userId]->addOnWiki(
NS_USER,
sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
ObjectCache::getLocalClusterInstance(),
wfMemcKey( "echo_on_wiki_whitelist_" . $userId )
);
}
return self::$userWhitelist[$userId]
->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], array( $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 = array();
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, array( $event ) );
$callable = reset( $spliced );
} else {
$options = array( $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 = array();
Hooks::run( 'EchoGetDefaultNotifiedUsers', array( $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 = array();
$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 per-wiki event blacklist and per-user whitelists
// of that blacklist.
if ( self::isBlacklisted( $event ) ) {
$notify->addFilter( function ( $user ) use ( $event ) {
// don't use self:: - PHP5.3 closures don't inherit class scope
return EchoNotificationController::isWhitelistedByUser( $event, $user );
} );
}
return $notify->getIterator();
}
/**
* Event has failed to format for the given user. Mark it as read so
* we do not continue to notify them about this broken event.
*
* @param EchoEvent $event
* @param User $user
*/
protected static function failFormatting( EchoEvent $event, $user ) {
// FIXME: The only issue is that the badge count won't be up to date
// till you refresh the page. Probably we could do this in the browser
// so that if the formatting is empty and the notif is unread, put it
// in the auto-mark-read APIs
EchoDeferredMarkAsReadUpdate::add( $event, $user );
}
/**
* 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 );
}
}