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