Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
5.29% |
24 / 454 |
|
5.26% |
1 / 19 |
CRAP | |
0.00% |
0 / 1 |
| Controller | |
5.29% |
24 / 454 |
|
5.26% |
1 / 19 |
8941.71 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| onBeforeCreateEchoEvent | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
2 | |||
| notifyHeaderChange | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
| notifyPostChange | |
0.00% |
0 / 85 |
|
0.00% |
0 / 1 |
182 | |||
| notifySummaryChange | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
56 | |||
| notifyNewTopic | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
110 | |||
| notifyTopicLocked | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
42 | |||
| notifyFlowEnabledOnTalkpage | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| generateMentionEvents | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
72 | |||
| getMentionedUsersAndSkipState | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| filterMentionedUsers | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| getMentionedUsersFromWikitext | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
| onEchoGetBundleRules | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
210 | |||
| isFirstPost | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| getTopmostPostId | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
| getFirstPreorderDepthFirst | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
5.15 | |||
| getDeepestCommonRoot | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
6.13 | |||
| moderateTopicNotifications | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
| moderatePostNotifications | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Flow\Notifications; |
| 4 | |
| 5 | use Flow\Conversion\Utils; |
| 6 | use Flow\Exception\FlowException; |
| 7 | use Flow\Model\AbstractRevision; |
| 8 | use Flow\Model\Header; |
| 9 | use Flow\Model\PostRevision; |
| 10 | use Flow\Model\PostSummary; |
| 11 | use Flow\Model\UUID; |
| 12 | use Flow\Model\Workflow; |
| 13 | use Flow\Repository\TreeRepository; |
| 14 | use MediaWiki\Config\ServiceOptions; |
| 15 | use MediaWiki\Deferred\DeferredUpdates; |
| 16 | use MediaWiki\Extension\Notifications\Controller\ModerationController; |
| 17 | use MediaWiki\Extension\Notifications\Mapper\EventMapper; |
| 18 | use MediaWiki\Extension\Notifications\Model\Event; |
| 19 | use MediaWiki\Language\Language; |
| 20 | use MediaWiki\MediaWikiServices; |
| 21 | use MediaWiki\Parser\ParserOptions; |
| 22 | use MediaWiki\Parser\ParserOutputLinkTypes; |
| 23 | use MediaWiki\Registration\ExtensionRegistry; |
| 24 | use MediaWiki\Title\Title; |
| 25 | use MediaWiki\User\User; |
| 26 | use Wikimedia\Rdbms\IDBAccessObject; |
| 27 | |
| 28 | class Controller { |
| 29 | /** |
| 30 | * @var Language |
| 31 | */ |
| 32 | protected $language; |
| 33 | |
| 34 | /** |
| 35 | * @var TreeRepository |
| 36 | */ |
| 37 | protected $treeRepository; |
| 38 | |
| 39 | public const CONSTRUCTOR_OPTIONS = [ |
| 40 | 'FlowNotificationTruncateLength' |
| 41 | ]; |
| 42 | private ?int $truncateLength; |
| 43 | |
| 44 | /** |
| 45 | * @param ServiceOptions $options |
| 46 | * @param Language $language |
| 47 | * @param TreeRepository $treeRepository |
| 48 | */ |
| 49 | public function __construct( ServiceOptions $options, Language $language, TreeRepository $treeRepository ) { |
| 50 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| 51 | $this->truncateLength = $options->get( 'FlowNotificationTruncateLength' ); |
| 52 | $this->language = $language; |
| 53 | $this->treeRepository = $treeRepository; |
| 54 | } |
| 55 | |
| 56 | public static function onBeforeCreateEchoEvent( &$notifs, &$categories, &$icons ) { |
| 57 | $notifs += require dirname( dirname( __DIR__ ) ) . "/Notifications.php"; |
| 58 | $categories['flow-discussion'] = [ |
| 59 | 'priority' => 3, |
| 60 | 'tooltip' => 'echo-pref-tooltip-flow-discussion', |
| 61 | ]; |
| 62 | $icons['flow-new-topic'] = [ |
| 63 | 'path' => [ |
| 64 | 'ltr' => 'Flow/modules/notification/icon/flow-new-topic-ltr.svg', |
| 65 | 'rtl' => 'Flow/modules/notification/icon/flow-new-topic-rtl.svg' |
| 66 | ] |
| 67 | ]; |
| 68 | $icons['flowusertalk-new-topic'] = [ |
| 69 | 'path' => [ |
| 70 | 'ltr' => 'Flow/modules/notification/icon/flow-new-topic-ltr.svg', |
| 71 | 'rtl' => 'Flow/modules/notification/icon/flow-new-topic-rtl.svg' |
| 72 | ] |
| 73 | ]; |
| 74 | $icons['flow-post-edited'] = $icons['flowusertalk-post-edited'] = [ |
| 75 | 'path' => [ |
| 76 | 'ltr' => 'Flow/modules/notification/icon/flow-post-edited-ltr.svg', |
| 77 | 'rtl' => 'Flow/modules/notification/icon/flow-post-edited-rtl.svg' |
| 78 | ] |
| 79 | ]; |
| 80 | $icons['flow-topic-renamed'] = $icons['flowusertalk-topic-renamed'] = [ |
| 81 | 'path' => [ |
| 82 | 'ltr' => 'Flow/modules/notification/icon/flow-topic-renamed-ltr.svg', |
| 83 | 'rtl' => 'Flow/modules/notification/icon/flow-topic-renamed-rtl.svg' |
| 84 | ] |
| 85 | ]; |
| 86 | $icons['flow-topic-resolved'] = $icons['flowusertalk-topic-resolved'] = [ |
| 87 | 'path' => [ |
| 88 | 'ltr' => 'Flow/modules/notification/icon/flow-topic-resolved-ltr.svg', |
| 89 | 'rtl' => 'Flow/modules/notification/icon/flow-topic-resolved-rtl.svg' |
| 90 | ] |
| 91 | ]; |
| 92 | $icons['flow-topic-reopened'] = $icons['flowusertalk-topic-reopened'] = [ |
| 93 | 'path' => [ |
| 94 | 'ltr' => 'Flow/modules/notification/icon/flow-topic-reopened-ltr.svg', |
| 95 | 'rtl' => 'Flow/modules/notification/icon/flow-topic-reopened-rtl.svg' |
| 96 | ] |
| 97 | ]; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Causes notifications to be fired for a Header-related event. |
| 102 | * @param array $data Associative array of parameters. |
| 103 | * * revision: The PostRevision created by the action. Always required. |
| 104 | * * board-workflow: The Workflow object for the board. Always required. |
| 105 | * * timestamp: Original event timestamp, for imports. Optional. |
| 106 | * * extra-data: Additional data to pass along to Event extra. |
| 107 | * @return Event[] |
| 108 | * @throws FlowException When $data contains unexpected types/values |
| 109 | */ |
| 110 | public function notifyHeaderChange( array $data ): array { |
| 111 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 112 | return []; |
| 113 | } |
| 114 | |
| 115 | $extraData = $data['extra-data'] ?? []; |
| 116 | |
| 117 | $revision = $data['revision']; |
| 118 | if ( !$revision instanceof Header ) { |
| 119 | throw new FlowException( 'Expected Header but received ' . get_class( $revision ) ); |
| 120 | } |
| 121 | $boardWorkflow = $data['board-workflow']; |
| 122 | if ( !$boardWorkflow instanceof Workflow ) { |
| 123 | throw new FlowException( 'Expected Workflow but received ' . get_class( $boardWorkflow ) ); |
| 124 | } |
| 125 | |
| 126 | $user = $revision->getUser(); |
| 127 | [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision ); |
| 128 | |
| 129 | $extraData['content'] = Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ); |
| 130 | $extraData['revision-id'] = $revision->getRevisionId(); |
| 131 | $extraData['collection-id'] = $revision->getCollectionId(); |
| 132 | $extraData['target-page'] = $boardWorkflow->getArticleTitle()->getArticleID(); |
| 133 | // pass along mentioned users to other notification, so it knows who to ignore |
| 134 | $extraData['mentioned-users'] = $mentionedUsers; |
| 135 | $title = $boardWorkflow->getOwnerTitle(); |
| 136 | |
| 137 | $info = [ |
| 138 | 'agent' => $user, |
| 139 | 'title' => $title, |
| 140 | 'extra' => $extraData, |
| 141 | ]; |
| 142 | |
| 143 | // Allow a specific timestamp to be set - useful when importing existing data |
| 144 | if ( isset( $data['timestamp'] ) ) { |
| 145 | $info['timestamp'] = $data['timestamp']; |
| 146 | } |
| 147 | |
| 148 | $events = [ Event::create( [ 'type' => 'flow-description-edited' ] + $info ) ]; |
| 149 | if ( $title->getNamespace() === NS_USER_TALK ) { |
| 150 | $events[] = Event::create( [ 'type' => 'flowusertalk-description-edited' ] + $info ); |
| 151 | } |
| 152 | return [ |
| 153 | ...$events, |
| 154 | ...$this->generateMentionEvents( |
| 155 | $revision, |
| 156 | null, |
| 157 | $boardWorkflow, |
| 158 | $user, |
| 159 | $mentionedUsers, |
| 160 | $mentionsSkipped |
| 161 | ) |
| 162 | ]; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Causes notifications to be fired for a Flow event. |
| 167 | * @param string $eventName The event that occurred. Choice of: |
| 168 | * * flow-post-reply |
| 169 | * * flow-topic-renamed |
| 170 | * * flow-post-edited |
| 171 | * @param array $data Associative array of parameters. |
| 172 | * * user: The user who made the change. Always required. |
| 173 | * * revision: The PostRevision created by the action. Always required. |
| 174 | * * title: The Title on which this Topic sits. Always required. |
| 175 | * * topic-workflow: The Workflow object for the topic. Always required. |
| 176 | * * topic-title: The Title of the Topic that the post belongs to. Required except for topic renames. |
| 177 | * * old-subject: The old subject of a Topic. Required for topic renames. |
| 178 | * * new-subject: The new subject of a Topic. Required for topic renames. |
| 179 | * @return Event[] |
| 180 | * @throws FlowException When $data contains unexpected types/values |
| 181 | */ |
| 182 | public function notifyPostChange( $eventName, array $data ): array { |
| 183 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 184 | return []; |
| 185 | } |
| 186 | |
| 187 | $extraData = $data['extra-data'] ?? []; |
| 188 | |
| 189 | $revision = $data['revision']; |
| 190 | if ( !$revision instanceof PostRevision ) { |
| 191 | throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) ); |
| 192 | } |
| 193 | $topicRevision = $data['topic-title']; |
| 194 | if ( !$topicRevision instanceof PostRevision ) { |
| 195 | throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) ); |
| 196 | } |
| 197 | $topicWorkflow = $data['topic-workflow']; |
| 198 | if ( !$topicWorkflow instanceof Workflow ) { |
| 199 | throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) ); |
| 200 | } |
| 201 | |
| 202 | $user = $revision->getUser(); |
| 203 | [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision ); |
| 204 | $title = $topicWorkflow->getOwnerTitle(); |
| 205 | |
| 206 | $extraData['revision-id'] = $revision->getRevisionId(); |
| 207 | $extraData['post-id'] = $revision->getPostId(); |
| 208 | $extraData['topic-workflow'] = $topicWorkflow->getId(); |
| 209 | $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID(); |
| 210 | // pass along mentioned users to other notification, so it knows who to ignore |
| 211 | $extraData['mentioned-users'] = $mentionedUsers; |
| 212 | |
| 213 | switch ( $eventName ) { |
| 214 | case 'flow-post-reply': |
| 215 | $extraData += [ |
| 216 | 'reply-to' => $revision->getReplyToId(), |
| 217 | 'content' => Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ), |
| 218 | 'topic-title' => $this->language->truncateForVisual( |
| 219 | $topicRevision->getContent( 'topic-title-plaintext' ), 200 |
| 220 | ), |
| 221 | ]; |
| 222 | |
| 223 | // if we're looking at the initial post (submitted along with the topic |
| 224 | // title), we don't want to send the flow-post-reply notification, |
| 225 | // because users will already receive flow-new-topic as well |
| 226 | if ( $this->isFirstPost( $revision, $topicWorkflow ) ) { |
| 227 | // if users were mentioned here, we'll want to make sure |
| 228 | // that they weren't also mentioned in the topic title (in |
| 229 | // which case they would get 2 notifications...) |
| 230 | if ( $mentionedUsers ) { |
| 231 | [ $mentionedInTitle, $mentionsSkippedInTitle ] = |
| 232 | $this->getMentionedUsersAndSkipState( $topicRevision ); |
| 233 | $mentionedUsers = array_diff_key( $mentionedUsers, $mentionedInTitle ); |
| 234 | $mentionsSkipped = $mentionsSkipped || $mentionsSkippedInTitle; |
| 235 | $extraData['mentioned-users'] = $mentionedUsers; |
| 236 | } |
| 237 | |
| 238 | return $this->generateMentionEvents( |
| 239 | $revision, |
| 240 | $topicRevision, |
| 241 | $topicWorkflow, |
| 242 | $user, |
| 243 | $mentionedUsers, |
| 244 | $mentionsSkipped |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | break; |
| 249 | case 'flow-topic-renamed': |
| 250 | $previousRevision = $revision->getCollection()->getPrevRevision( $revision ); |
| 251 | $extraData += [ |
| 252 | 'old-subject' => $this->language->truncateForVisual( |
| 253 | $previousRevision->getContent( 'topic-title-plaintext' ), 200 |
| 254 | ), |
| 255 | 'new-subject' => $this->language->truncateForVisual( |
| 256 | $revision->getContent( 'topic-title-plaintext' ), 200 |
| 257 | ), |
| 258 | ]; |
| 259 | break; |
| 260 | case 'flow-post-edited': |
| 261 | $extraData += [ |
| 262 | 'content' => Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ), |
| 263 | 'topic-title' => $this->language->truncateForVisual( |
| 264 | $topicRevision->getContent( 'topic-title-plaintext' ), 200 |
| 265 | ), |
| 266 | ]; |
| 267 | break; |
| 268 | } |
| 269 | |
| 270 | $info = [ |
| 271 | 'agent' => $user, |
| 272 | 'title' => $title, |
| 273 | 'extra' => $extraData, |
| 274 | ]; |
| 275 | |
| 276 | // Allow a specific timestamp to be set - useful when importing existing data |
| 277 | if ( isset( $data['timestamp'] ) ) { |
| 278 | $info['timestamp'] = $data['timestamp']; |
| 279 | } |
| 280 | |
| 281 | $events = [ Event::create( [ 'type' => $eventName ] + $info ) ]; |
| 282 | if ( $title->getNamespace() === NS_USER_TALK ) { |
| 283 | $usertalkEvent = str_replace( 'flow-', 'flowusertalk-', $eventName ); |
| 284 | $events[] = Event::create( [ 'type' => $usertalkEvent ] + $info ); |
| 285 | } |
| 286 | return [ |
| 287 | ...$events, |
| 288 | ...$this->generateMentionEvents( |
| 289 | $revision, |
| 290 | $topicRevision, |
| 291 | $topicWorkflow, |
| 292 | $user, |
| 293 | $mentionedUsers, |
| 294 | $mentionsSkipped |
| 295 | ) |
| 296 | ]; |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Causes notifications to be fired for a Summary-related event. |
| 301 | * @param array $data Associative array of parameters. |
| 302 | * * revision: The PostRevision created by the action. Always required. |
| 303 | * * topic-title: The PostRevision object for the topic title. Always required. |
| 304 | * * topic-workflow: The Workflow object for the board. Always required. |
| 305 | * * extra-data: Additional data to pass along to Event extra. |
| 306 | * @return Event[] |
| 307 | * @throws FlowException When $data contains unexpected types/values |
| 308 | */ |
| 309 | public function notifySummaryChange( array $data ): array { |
| 310 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 311 | return []; |
| 312 | } |
| 313 | |
| 314 | $revision = $data['revision']; |
| 315 | if ( !$revision instanceof PostSummary ) { |
| 316 | throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) ); |
| 317 | } |
| 318 | $topicRevision = $data['topic-title']; |
| 319 | if ( !$topicRevision instanceof PostRevision ) { |
| 320 | throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicRevision ) ); |
| 321 | } |
| 322 | $topicWorkflow = $data['topic-workflow']; |
| 323 | if ( !$topicWorkflow instanceof Workflow ) { |
| 324 | throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) ); |
| 325 | } |
| 326 | |
| 327 | $user = $revision->getUser(); |
| 328 | [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $revision ); |
| 329 | |
| 330 | $extraData = []; |
| 331 | $extraData['content'] = Utils::htmlToPlaintext( $revision->getContent(), $this->truncateLength, $this->language ); |
| 332 | $extraData['revision-id'] = $revision->getRevisionId(); |
| 333 | $extraData['prev-revision-id'] = $revision->getPrevRevisionId(); |
| 334 | $extraData['topic-workflow'] = $topicWorkflow->getId(); |
| 335 | $extraData['topic-title'] = $this->language->truncateForVisual( |
| 336 | $topicRevision->getContent( 'topic-title-plaintext' ), 200 |
| 337 | ); |
| 338 | $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID(); |
| 339 | // pass along mentioned users to other notification, so it knows who to ignore |
| 340 | $extraData['mentioned-users'] = $mentionedUsers; |
| 341 | $title = $topicWorkflow->getOwnerTitle(); |
| 342 | |
| 343 | $info = [ |
| 344 | 'agent' => $user, |
| 345 | 'title' => $title, |
| 346 | 'extra' => $extraData, |
| 347 | ]; |
| 348 | |
| 349 | // Allow a specific timestamp to be set - useful when importing existing data |
| 350 | if ( isset( $data['timestamp'] ) ) { |
| 351 | $info['timestamp'] = $data['timestamp']; |
| 352 | } |
| 353 | |
| 354 | $events = [ Event::create( [ 'type' => 'flow-summary-edited' ] + $info ) ]; |
| 355 | if ( $title->getNamespace() === NS_USER_TALK ) { |
| 356 | $events[] = Event::create( [ 'type' => 'flowusertalk-summary-edited' ] + $info ); |
| 357 | } |
| 358 | return [ |
| 359 | ...$events, |
| 360 | ...$this->generateMentionEvents( |
| 361 | $revision, |
| 362 | $topicRevision, |
| 363 | $topicWorkflow, |
| 364 | $user, |
| 365 | $mentionedUsers, |
| 366 | $mentionsSkipped |
| 367 | ) |
| 368 | ]; |
| 369 | } |
| 370 | |
| 371 | /** |
| 372 | * Triggers notifications for a new topic. |
| 373 | * @param array $params Associative array of parameters, all required: |
| 374 | * * board-workflow: Workflow object for the Flow board. |
| 375 | * * topic-workflow: Workflow object for the new Topic. |
| 376 | * * topic-title: PostRevision object for the "topic post", containing the |
| 377 | * title. |
| 378 | * * first-post: PostRevision object for the first post, or null when no first post. |
| 379 | * * user: The User who created the topic. |
| 380 | * @return Event[] |
| 381 | * @throws FlowException When $params contains unexpected types/values |
| 382 | */ |
| 383 | public function notifyNewTopic( array $params ): array { |
| 384 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 385 | // Nothing to do here. |
| 386 | return []; |
| 387 | } |
| 388 | |
| 389 | $topicWorkflow = $params['topic-workflow']; |
| 390 | if ( !$topicWorkflow instanceof Workflow ) { |
| 391 | throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) ); |
| 392 | } |
| 393 | $topicTitle = $params['topic-title']; |
| 394 | if ( !$topicTitle instanceof PostRevision ) { |
| 395 | throw new FlowException( 'Expected PostRevision but received ' . get_class( $topicTitle ) ); |
| 396 | } |
| 397 | $firstPost = $params['first-post']; |
| 398 | if ( $firstPost !== null && !$firstPost instanceof PostRevision ) { |
| 399 | throw new FlowException( 'Expected PostRevision but received ' . get_class( $firstPost ) ); |
| 400 | } |
| 401 | $user = $topicTitle->getUser(); |
| 402 | $boardWorkflow = $params['board-workflow']; |
| 403 | if ( !$boardWorkflow instanceof Workflow ) { |
| 404 | throw new FlowException( 'Expected Workflow but received ' . get_class( $boardWorkflow ) ); |
| 405 | } |
| 406 | |
| 407 | [ $mentionedUsers, $mentionsSkipped ] = $this->getMentionedUsersAndSkipState( $topicTitle ); |
| 408 | |
| 409 | $title = $boardWorkflow->getArticleTitle(); |
| 410 | $events = []; |
| 411 | $eventData = [ |
| 412 | 'agent' => $user, |
| 413 | 'title' => $title, |
| 414 | 'extra' => [ |
| 415 | 'board-workflow' => $boardWorkflow->getId(), |
| 416 | 'topic-workflow' => $topicWorkflow->getId(), |
| 417 | 'post-id' => $firstPost ? $firstPost->getRevisionId() : null, |
| 418 | 'topic-title' => $this->language->truncateForVisual( $topicTitle->getContent( 'topic-title-plaintext' ), 200 ), |
| 419 | 'content' => $firstPost |
| 420 | ? Utils::htmlToPlaintext( $firstPost->getContent(), $this->truncateLength, $this->language ) |
| 421 | : null, |
| 422 | // Force a read from primary database since this could be a new page |
| 423 | 'target-page' => [ |
| 424 | $topicWorkflow->getOwnerTitle()->getArticleID( IDBAccessObject::READ_LATEST ), |
| 425 | $topicWorkflow->getArticleTitle()->getArticleID( IDBAccessObject::READ_LATEST ), |
| 426 | ], |
| 427 | // pass along mentioned users to other notification, so it knows who to ignore |
| 428 | // also look at users mentioned in first post: if there are any, this |
| 429 | // (flow-new-topic) notification shouldn't go through (because they'll |
| 430 | // already receive the mention notification) |
| 431 | 'mentioned-users' => $mentionedUsers, |
| 432 | ] |
| 433 | ]; |
| 434 | $events[] = Event::create( [ 'type' => 'flow-new-topic' ] + $eventData ); |
| 435 | if ( $title->getNamespace() === NS_USER_TALK ) { |
| 436 | $events[] = Event::create( [ 'type' => 'flowusertalk-new-topic' ] + $eventData ); |
| 437 | } |
| 438 | |
| 439 | return [ |
| 440 | ...$events, |
| 441 | ...$this->generateMentionEvents( |
| 442 | $topicTitle, |
| 443 | $topicTitle, |
| 444 | $topicWorkflow, |
| 445 | $user, |
| 446 | $mentionedUsers, |
| 447 | $mentionsSkipped |
| 448 | ) |
| 449 | ]; |
| 450 | } |
| 451 | |
| 452 | /** |
| 453 | * Triggers notifications when a topic is resolved or reopened. |
| 454 | * |
| 455 | * @param string $type flow-topic-resolved|flow-topic-reopened |
| 456 | * @param array $data |
| 457 | * @return Event[] |
| 458 | */ |
| 459 | public function notifyTopicLocked( $type, array $data ): array { |
| 460 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 461 | return []; |
| 462 | } |
| 463 | |
| 464 | $revision = $data['revision']; |
| 465 | if ( !$revision instanceof PostRevision ) { |
| 466 | throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) ); |
| 467 | } |
| 468 | $topicWorkflow = $data['topic-workflow']; |
| 469 | if ( !$topicWorkflow instanceof Workflow ) { |
| 470 | throw new FlowException( 'Expected Workflow but received ' . get_class( $topicWorkflow ) ); |
| 471 | } |
| 472 | |
| 473 | $extraData = []; |
| 474 | $extraData['topic-workflow'] = $topicWorkflow->getId(); |
| 475 | $extraData['topic-title'] = Utils::htmlToPlaintext( $revision->getContent( 'topic-title-html' ), 200, $this->language ); |
| 476 | $extraData['target-page'] = $topicWorkflow->getArticleTitle()->getArticleID(); |
| 477 | // I'll treat resolve & reopen as the same notification type, but pass the |
| 478 | // different type so presentation models can differentiate |
| 479 | $extraData['type'] = $type; |
| 480 | $title = $topicWorkflow->getOwnerTitle(); |
| 481 | |
| 482 | $info = [ |
| 483 | 'agent' => $revision->getUser(), |
| 484 | 'title' => $title, |
| 485 | 'extra' => $extraData, |
| 486 | ]; |
| 487 | |
| 488 | // Allow a specific timestamp to be set - useful when importing existing data |
| 489 | if ( isset( $data['timestamp'] ) ) { |
| 490 | $info['timestamp'] = $data['timestamp']; |
| 491 | } |
| 492 | |
| 493 | $events = [ Event::create( [ 'type' => 'flow-topic-resolved' ] + $info ) ]; |
| 494 | if ( $title->getNamespace() === NS_USER_TALK ) { |
| 495 | $events[] = Event::create( [ 'type' => 'flowusertalk-topic-resolved' ] + $info ); |
| 496 | } |
| 497 | return $events; |
| 498 | } |
| 499 | |
| 500 | public function notifyFlowEnabledOnTalkpage( User $user ) { |
| 501 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 502 | // Nothing to do here. |
| 503 | return []; |
| 504 | } |
| 505 | |
| 506 | $events = []; |
| 507 | $events[] = Event::create( [ |
| 508 | 'type' => 'flow-enabled-on-talkpage', |
| 509 | 'agent' => $user, |
| 510 | 'title' => $user->getTalkPage(), |
| 511 | ] ); |
| 512 | |
| 513 | return $events; |
| 514 | } |
| 515 | |
| 516 | /** |
| 517 | * @param AbstractRevision $content The (post|topic|header) revision that contains the content of the mention |
| 518 | * @param PostRevision|null $topic Topic PostRevision object, if relevant (e.g. not for Header) |
| 519 | * @param Workflow $workflow |
| 520 | * @param User $user User who created the new post |
| 521 | * @param int[] $mentionedUsers |
| 522 | * @param bool $mentionsSkipped Were mentions skipped due to too many mentions being attempted? |
| 523 | * @return Event[] |
| 524 | * @throws \Flow\Exception\InvalidDataException |
| 525 | */ |
| 526 | protected function generateMentionEvents( |
| 527 | AbstractRevision $content, |
| 528 | ?PostRevision $topic, |
| 529 | Workflow $workflow, |
| 530 | User $user, |
| 531 | array $mentionedUsers, |
| 532 | $mentionsSkipped |
| 533 | ): array { |
| 534 | global $wgEchoMentionStatusNotifications, $wgFlowMaxMentionCount; |
| 535 | |
| 536 | if ( !$mentionedUsers ) { |
| 537 | return []; |
| 538 | } |
| 539 | |
| 540 | $extraData = []; |
| 541 | $extraData['mentioned-users'] = $mentionedUsers; |
| 542 | $extraData['target-page'] = $workflow->getArticleTitle()->getArticleID(); |
| 543 | // don't include topic content again if the notification IS in the title |
| 544 | $extraData['content'] = $content === $topic ? '' : |
| 545 | Utils::htmlToPlaintext( $content->getContent(), $this->truncateLength, $this->language ); |
| 546 | // lets us differentiate between different revision types |
| 547 | $extraData['revision-type'] = $content->getRevisionType(); |
| 548 | |
| 549 | // additional data needed to render link to post |
| 550 | if ( $extraData['revision-type'] === 'post' ) { |
| 551 | $extraData['post-id'] = $content->getCollection()->getId(); |
| 552 | } |
| 553 | // needed to render topic title text & link to topic |
| 554 | if ( $topic !== null ) { |
| 555 | $extraData['topic-workflow'] = $workflow->getId(); |
| 556 | $extraData['topic-title'] = $this->language->truncateForVisual( $topic->getContent( 'topic-title-plaintext' ), 200 ); |
| 557 | } |
| 558 | |
| 559 | $events = []; |
| 560 | $events[] = Event::create( [ |
| 561 | 'type' => 'flow-mention', |
| 562 | 'title' => $workflow->getOwnerTitle(), |
| 563 | 'extra' => $extraData, |
| 564 | 'agent' => $user, |
| 565 | ] ); |
| 566 | if ( $wgEchoMentionStatusNotifications && $mentionsSkipped ) { |
| 567 | $extra = [ |
| 568 | 'topic-workflow' => $workflow->getId(), |
| 569 | 'max-mentions' => $wgFlowMaxMentionCount, |
| 570 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset |
| 571 | 'section-title' => $extraData['topic-title'], |
| 572 | 'failure-type' => 'too-many', |
| 573 | ]; |
| 574 | if ( $content->getRevisionType() === 'post' ) { |
| 575 | $extra['post-id'] = $content->getCollection()->getId(); |
| 576 | } |
| 577 | $events[] = Event::create( [ |
| 578 | 'type' => 'flow-mention-failure-too-many', |
| 579 | 'title' => $workflow->getOwnerTitle(), |
| 580 | 'extra' => $extra, |
| 581 | 'agent' => $user, |
| 582 | ] ); |
| 583 | } |
| 584 | return $events; |
| 585 | } |
| 586 | |
| 587 | /** |
| 588 | * Analyses a PostRevision to determine which users are mentioned. |
| 589 | * |
| 590 | * @param AbstractRevision $revision The Post to analyse. |
| 591 | * @return array |
| 592 | * 0 => int[] Array of user IDs |
| 593 | * 1 => bool Were some mentions ignored due to $wgFlowMaxMentionCount? |
| 594 | * @phan-return array{0:int[],1:bool} |
| 595 | */ |
| 596 | protected function getMentionedUsersAndSkipState( AbstractRevision $revision ) { |
| 597 | // At the moment, it is not possible to get a list of mentioned users from HTML |
| 598 | // unless that HTML comes from Parsoid. But VisualEditor (what is currently used |
| 599 | // to convert wikitext to HTML) does not currently use Parsoid. |
| 600 | $wikitext = $revision->getContentInWikitext(); |
| 601 | $mentions = $this->getMentionedUsersFromWikitext( $wikitext ); |
| 602 | |
| 603 | // if this post had a previous revision (= this is an edit), we don't |
| 604 | // want to pick up on the same mentions as in the previous edit, only |
| 605 | // new mentions |
| 606 | $previousRevision = $revision->getCollection()->getPrevRevision( $revision ); |
| 607 | if ( $previousRevision !== null ) { |
| 608 | $previousWikitext = $previousRevision->getContentInWikitext(); |
| 609 | $previousMentions = $this->getMentionedUsersFromWikitext( $previousWikitext ); |
| 610 | $mentions = array_diff( $mentions, $previousMentions ); |
| 611 | } |
| 612 | |
| 613 | return $this->filterMentionedUsers( $mentions, $revision ); |
| 614 | } |
| 615 | |
| 616 | /** |
| 617 | * Process an array of users linked to in a comment into a list of users |
| 618 | * who should actually be notified. |
| 619 | * |
| 620 | * Removes duplicates, anonymous users, self-mentions, and mentions of the |
| 621 | * owner of the talk page |
| 622 | * @param User[] $mentions |
| 623 | * @param AbstractRevision $revision The Post that is being examined. |
| 624 | * @return array |
| 625 | * 0 => int[] Array of user IDs |
| 626 | * 1 => bool Were some mentions ignored due to $wgFlowMaxMentionCount? |
| 627 | * @phan-return array{0:int[],1:bool} |
| 628 | */ |
| 629 | protected function filterMentionedUsers( $mentions, AbstractRevision $revision ) { |
| 630 | global $wgFlowMaxMentionCount; |
| 631 | |
| 632 | $outputMentions = []; |
| 633 | $mentionsSkipped = false; |
| 634 | |
| 635 | foreach ( $mentions as $mentionedUser ) { |
| 636 | // Don't notify anonymous users |
| 637 | if ( !$mentionedUser->isRegistered() ) { |
| 638 | continue; |
| 639 | } |
| 640 | |
| 641 | // Don't notify the user who made the post |
| 642 | if ( $mentionedUser->getId() == $revision->getUserId() ) { |
| 643 | continue; |
| 644 | } |
| 645 | |
| 646 | if ( count( $outputMentions ) >= $wgFlowMaxMentionCount ) { |
| 647 | $mentionsSkipped = true; |
| 648 | break; |
| 649 | } |
| 650 | |
| 651 | $outputMentions[$mentionedUser->getId()] = $mentionedUser->getId(); |
| 652 | } |
| 653 | |
| 654 | return [ $outputMentions, $mentionsSkipped ]; |
| 655 | } |
| 656 | |
| 657 | /** |
| 658 | * Examines a wikitext string and finds users that were mentioned |
| 659 | * @param string $wikitext |
| 660 | * @return User[] |
| 661 | */ |
| 662 | protected function getMentionedUsersFromWikitext( $wikitext ) { |
| 663 | $title = Title::newMainPage(); // Bogus title used for parser |
| 664 | |
| 665 | $options = ParserOptions::newFromAnon(); |
| 666 | |
| 667 | $output = MediaWikiServices::getInstance()->getParser() |
| 668 | ->parse( $wikitext, $title, $options ); |
| 669 | |
| 670 | $users = []; |
| 671 | foreach ( $output->getLinkList( ParserOutputLinkTypes::LOCAL, NS_USER ) |
| 672 | as [ 'link' => $link ] ) { |
| 673 | $user = User::newFromName( $link->getDBkey() ); |
| 674 | if ( !$user || !$user->isRegistered() ) { |
| 675 | continue; |
| 676 | } |
| 677 | |
| 678 | $users[$user->getId()] = $user; |
| 679 | } |
| 680 | |
| 681 | return $users; |
| 682 | } |
| 683 | |
| 684 | /** |
| 685 | * Handler for EchoGetBundleRule hook, which defines the bundle rules for each notification |
| 686 | * |
| 687 | * @param Event $event |
| 688 | * @param string &$bundleString Determines how the notification should be bundled |
| 689 | * @return bool True for success |
| 690 | */ |
| 691 | public static function onEchoGetBundleRules( $event, &$bundleString ) { |
| 692 | switch ( $event->getType() ) { |
| 693 | case 'flow-new-topic': |
| 694 | case 'flowusertalk-new-topic': |
| 695 | $board = $event->getExtraParam( 'board-workflow' ); |
| 696 | if ( $board instanceof UUID ) { |
| 697 | $bundleString = $event->getType() . '-' . $board->getAlphadecimal(); |
| 698 | } |
| 699 | break; |
| 700 | |
| 701 | case 'flow-post-reply': |
| 702 | case 'flowusertalk-post-reply': |
| 703 | case 'flow-post-edited': |
| 704 | case 'flowusertalk-post-edited': |
| 705 | case 'flow-summary-edited': |
| 706 | case 'flowusertalk-summary-edited': |
| 707 | $topic = $event->getExtraParam( 'topic-workflow' ); |
| 708 | if ( $topic instanceof UUID ) { |
| 709 | $bundleString = $event->getType() . '-' . $topic->getAlphadecimal(); |
| 710 | } |
| 711 | break; |
| 712 | |
| 713 | case 'flow-description-edited': |
| 714 | case 'flowusertalk-description-edited': |
| 715 | $headerId = $event->getExtraParam( 'collection-id' ); |
| 716 | if ( $headerId instanceof UUID ) { |
| 717 | $bundleString = $event->getType() . '-' . $headerId->getAlphadecimal(); |
| 718 | } |
| 719 | break; |
| 720 | } |
| 721 | return true; |
| 722 | } |
| 723 | |
| 724 | /** |
| 725 | * @param PostRevision $revision |
| 726 | * @param Workflow $workflow |
| 727 | * @return bool |
| 728 | */ |
| 729 | protected function isFirstPost( PostRevision $revision, Workflow $workflow ) { |
| 730 | $postId = $revision->getPostId(); |
| 731 | $workflowId = $workflow->getId(); |
| 732 | $replyToId = $revision->getReplyToId(); |
| 733 | |
| 734 | // if the post is not a direct reply to the topic, it definitely can't be |
| 735 | // first post |
| 736 | if ( !$replyToId->equals( $workflowId ) ) { |
| 737 | return false; |
| 738 | } |
| 739 | |
| 740 | /* |
| 741 | * We don't want to go fetch the entire topic tree, so we'll use a crude |
| 742 | * technique to figure out if we're dealing with the first post: check if |
| 743 | * they were posted at (almost) the exact same time. |
| 744 | * If they're more than 1 second apart, it's very likely a not-first-post |
| 745 | * (or a very slow server, upgrade your machine!). False positives on the |
| 746 | * other side are also very rare: who on earth can refresh the page, read |
| 747 | * the post and write a meaningful reply in just 1 second? :) |
| 748 | */ |
| 749 | $diff = (int)$postId->getTimestamp( TS_UNIX ) - (int)$workflowId->getTimestamp( TS_UNIX ); |
| 750 | return $diff <= 1; |
| 751 | } |
| 752 | |
| 753 | /** |
| 754 | * Gets ID of topmost post |
| 755 | * |
| 756 | * This is the lowest-number post, numbering them using a pre-order depth-first |
| 757 | * search |
| 758 | * |
| 759 | * @param Event[] $bundledEvents |
| 760 | * @return UUID|null Post ID, or null on failure |
| 761 | */ |
| 762 | public function getTopmostPostId( array $bundledEvents ) { |
| 763 | $postIds = []; |
| 764 | foreach ( $bundledEvents as $event ) { |
| 765 | $postId = $event->getExtraParam( 'post-id' ); |
| 766 | if ( $postId instanceof UUID ) { |
| 767 | $postIds[$postId->getAlphadecimal()] = $postId; |
| 768 | } |
| 769 | } |
| 770 | |
| 771 | $rootPaths = $this->treeRepository->findRootPaths( $postIds ); |
| 772 | |
| 773 | // We do this so we don't have to walk the whole topic. |
| 774 | $deepestCommonRoot = $this->getDeepestCommonRoot( $rootPaths ); |
| 775 | |
| 776 | $subtree = $this->treeRepository->fetchSubtreeIdentityMap( $deepestCommonRoot ); |
| 777 | |
| 778 | $topmostPostId = $this->getFirstPreorderDepthFirst( $postIds, $deepestCommonRoot, $subtree ); |
| 779 | return $topmostPostId; |
| 780 | } |
| 781 | |
| 782 | /** |
| 783 | * Walks a (sub)tree in pre-order depth-first search order and return the first |
| 784 | * post ID from a specified list |
| 785 | * |
| 786 | * @param array $relevantPostIds Associative array mapping alphadecimal post ID to |
| 787 | * UUID post ID |
| 788 | * @param UUID $root Root node |
| 789 | * @param array $tree Tree structure |
| 790 | * @return UUID|null First post ID found, or null on failure |
| 791 | */ |
| 792 | protected function getFirstPreorderDepthFirst( array $relevantPostIds, UUID $root, array $tree ) { |
| 793 | $rootAlpha = $root->getAlphadecimal(); |
| 794 | |
| 795 | if ( isset( $relevantPostIds[$rootAlpha] ) ) { |
| 796 | return $root; |
| 797 | } |
| 798 | |
| 799 | if ( isset( $tree[$rootAlpha]['children'] ) ) { |
| 800 | $children = array_keys( $tree[$rootAlpha]['children'] ); |
| 801 | } else { |
| 802 | $children = []; |
| 803 | } |
| 804 | |
| 805 | foreach ( $children as $child ) { |
| 806 | $relevantPostId = $this->getFirstPreorderDepthFirst( $relevantPostIds, UUID::create( $child ), $tree ); |
| 807 | if ( $relevantPostId !== null ) { |
| 808 | return $relevantPostId; |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | return null; |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * Gets the deepest common root post |
| 817 | * |
| 818 | * This is the root of the smallest subtree all the posts are in. |
| 819 | * |
| 820 | * @param array[] $rootPaths Associative array mapping post IDs to root paths |
| 821 | * @return UUID|null Common root, or null on failure |
| 822 | */ |
| 823 | protected function getDeepestCommonRoot( array $rootPaths ) { |
| 824 | if ( count( $rootPaths ) == 0 ) { |
| 825 | return null; |
| 826 | } |
| 827 | |
| 828 | $deepestRoot = null; |
| 829 | $possibleDeepestRoot = null; |
| 830 | |
| 831 | $firstPath = reset( $rootPaths ); |
| 832 | $pathLength = count( $firstPath ); |
| 833 | |
| 834 | for ( $i = 0; $i < $pathLength; $i++ ) { |
| 835 | $possibleDeepestRoot = $firstPath[$i]; |
| 836 | |
| 837 | foreach ( $rootPaths as $path ) { |
| 838 | if ( !isset( $path[$i] ) || !$path[$i]->equals( $possibleDeepestRoot ) ) { |
| 839 | // Mismatch. Return the last match we found |
| 840 | return $deepestRoot; |
| 841 | } |
| 842 | } |
| 843 | |
| 844 | $deepestRoot = $possibleDeepestRoot; |
| 845 | } |
| 846 | |
| 847 | return $deepestRoot; |
| 848 | } |
| 849 | |
| 850 | /** |
| 851 | * Moderate or unmoderate Flow notifications associated with a topic. |
| 852 | * |
| 853 | * @param UUID $topicId |
| 854 | * @param bool $moderated Whether the events need to be moderated or unmoderated |
| 855 | * @throws FlowException |
| 856 | */ |
| 857 | public function moderateTopicNotifications( UUID $topicId, $moderated ) { |
| 858 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 859 | // Nothing to do here. |
| 860 | return; |
| 861 | } |
| 862 | |
| 863 | $title = Title::makeTitle( NS_TOPIC, ucfirst( $topicId->getAlphadecimal() ) ); |
| 864 | $pageId = $title->getArticleID(); |
| 865 | DeferredUpdates::addCallableUpdate( static function () use ( $pageId, $moderated ) { |
| 866 | $eventMapper = new EventMapper(); |
| 867 | $eventIds = $eventMapper->fetchIdsByPage( $pageId ); |
| 868 | |
| 869 | ModerationController::moderate( $eventIds, $moderated ); |
| 870 | } ); |
| 871 | } |
| 872 | |
| 873 | /** |
| 874 | * Moderate or unmoderate Flow notifications associated with a post within a topic. |
| 875 | * |
| 876 | * @param UUID $topicId |
| 877 | * @param UUID $postId |
| 878 | * @param bool $moderated Whether the events need to be moderated or unmoderated |
| 879 | * @throws FlowException |
| 880 | */ |
| 881 | public function moderatePostNotifications( UUID $topicId, UUID $postId, $moderated ) { |
| 882 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
| 883 | // Nothing to do here. |
| 884 | return; |
| 885 | } |
| 886 | |
| 887 | $title = Title::makeTitle( NS_TOPIC, ucfirst( $topicId->getAlphadecimal() ) ); |
| 888 | $pageId = $title->getArticleID(); |
| 889 | DeferredUpdates::addCallableUpdate( static function () use ( $pageId, $postId, $moderated ) { |
| 890 | $eventMapper = new EventMapper(); |
| 891 | $moderatedPostIdAlpha = $postId->getAlphadecimal(); |
| 892 | $eventIds = []; |
| 893 | |
| 894 | $events = $eventMapper->fetchByPage( $pageId ); |
| 895 | |
| 896 | foreach ( $events as $event ) { |
| 897 | /** @var UUID|string $eventPostId */ |
| 898 | $eventPostId = $event->getExtraParam( 'post-id' ); |
| 899 | $eventPostIdAlpha = $eventPostId instanceof UUID ? $eventPostId->getAlphadecimal() : $eventPostId; |
| 900 | if ( $eventPostIdAlpha === $moderatedPostIdAlpha ) { |
| 901 | $eventIds[] = $event->getId(); |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | ModerationController::moderate( $eventIds, $moderated ); |
| 906 | } ); |
| 907 | } |
| 908 | } |