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