Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.88% covered (danger)
40.88%
74 / 181
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventDispatcher
40.88% covered (danger)
40.88%
74 / 181
18.18% covered (danger)
18.18%
2 / 11
1052.59
0.00% covered (danger)
0.00%
0 / 1
 getParsedRevision
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 generateEventsForRevision
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 groupCommentsByThreadAndName
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 groupSubscribableHeadings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 findAddedItems
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 generateEventsFromItemSets
77.19% covered (warning)
77.19%
44 / 57
0.00% covered (danger)
0.00%
0 / 1
19.04
 addCommentChangeTag
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addAutoSubscription
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 locateSubscribedUsers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 logAddedComments
10.00% covered (danger)
10.00%
4 / 40
0.00% covered (danger)
0.00%
0 / 1
227.68
 inEventSample
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * DiscussionTools event dispatcher
4 *
5 * @file
6 * @ingroup Extensions
7 * @license MIT
8 */
9
10namespace MediaWiki\Extension\DiscussionTools\Notifications;
11
12use ChangeTags;
13use DateInterval;
14use DateTimeImmutable;
15use DeferredUpdates;
16use EchoEvent;
17use Error;
18use ExtensionRegistry;
19use IDBAccessObject;
20use Iterator;
21use MediaWiki\Extension\DiscussionTools\ContentThreadItemSet;
22use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
23use MediaWiki\Extension\DiscussionTools\SubscriptionItem;
24use MediaWiki\Extension\DiscussionTools\SubscriptionStore;
25use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
26use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
27use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
28use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
29use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
30use MediaWiki\Extension\EventLogging\EventLogging;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Page\PageIdentity;
33use MediaWiki\Revision\RevisionRecord;
34use MediaWiki\User\UserIdentity;
35use ParserOptions;
36use RequestContext;
37use Title;
38use TitleValue;
39use Wikimedia\Assert\Assert;
40use Wikimedia\Parsoid\Utils\DOMCompat;
41use Wikimedia\Parsoid\Utils\DOMUtils;
42
43class EventDispatcher {
44    /**
45     * @param RevisionRecord $revRecord
46     * @return ContentThreadItemSet
47     */
48    private static function getParsedRevision( RevisionRecord $revRecord ): ContentThreadItemSet {
49        $services = MediaWikiServices::getInstance();
50
51        $pageRecord = $services->getPageStore()->getPageById( $revRecord->getPageId() ) ?:
52            $services->getPageStore()->getPageById( $revRecord->getPageId(), IDBAccessObject::READ_LATEST );
53
54        Assert::postcondition( $pageRecord !== null, 'Revision had no page' );
55
56        // If the $revRecord was fetched from the primary database, this will also fetch the content
57        // from the primary database (using the same query flags)
58        $status = $services->getParserOutputAccess()->getParserOutput(
59            $pageRecord,
60            ParserOptions::newFromAnon(),
61            $revRecord
62        );
63        if ( !$status->isOK() ) {
64            throw new Error( 'Could not load revision for notifications' );
65        }
66
67        $title = TitleValue::newFromPage( $revRecord->getPage() );
68
69        $parserOutput = $status->getValue();
70        $html = $parserOutput->getText();
71
72        $doc = DOMUtils::parseHTML( $html );
73        $container = DOMCompat::getBody( $doc );
74        $parser = $services->getService( 'DiscussionTools.CommentParser' );
75        return $parser->parse( $container, $title );
76    }
77
78    /**
79     * @param array &$events
80     * @param RevisionRecord $newRevRecord
81     */
82    public static function generateEventsForRevision( array &$events, RevisionRecord $newRevRecord ): void {
83        $services = MediaWikiServices::getInstance();
84
85        $revisionStore = $services->getRevisionStore();
86        $oldRevRecord = $revisionStore->getPreviousRevision( $newRevRecord, IDBAccessObject::READ_LATEST );
87
88        $title = Title::newFromLinkTarget(
89            $newRevRecord->getPageAsLinkTarget()
90        );
91        if ( !HookUtils::isAvailableForTitle( $title ) ) {
92            // Not a talk page
93            return;
94        }
95
96        $user = $newRevRecord->getUser();
97        if ( !$user ) {
98            // User can be null if the user is deleted, but this is unlikely
99            // to be the case if the user just made an edit
100            return;
101        }
102
103        if ( $oldRevRecord !== null ) {
104            $oldItemSet = static::getParsedRevision( $oldRevRecord );
105        } else {
106            // Page creation
107            $doc = DOMUtils::parseHTML( '' );
108            $container = DOMCompat::getBody( $doc );
109            $oldItemSet = $services->getService( 'DiscussionTools.CommentParser' )
110                ->parse( $container, $title->getTitleValue() );
111        }
112        $newItemSet = static::getParsedRevision( $newRevRecord );
113
114        static::generateEventsFromItemSets( $events, $oldItemSet, $newItemSet, $newRevRecord, $title, $user );
115    }
116
117    /**
118     * For each level 2 heading, get a list of comments in the thread grouped by names, then IDs.
119     * (Compare by name first, as ID could be changed by a parent comment being moved/deleted.)
120     * Comments in level 3+ sub-threads are grouped together with the parent thread.
121     *
122     * For any other headings (including level 3+ before the first level 2 heading, level 1, and
123     * section zero placeholder headings), ignore comments in those threads.
124     *
125     * @param ContentThreadItem[] $items
126     * @return ContentCommentItem[][][]
127     */
128    private static function groupCommentsByThreadAndName( array $items ): array {
129        $comments = [];
130        $threadName = null;
131        foreach ( $items as $item ) {
132            if ( $item instanceof HeadingItem && ( $item->getHeadingLevel() < 2 || $item->isPlaceholderHeading() ) ) {
133                $threadName = null;
134            } elseif ( $item instanceof HeadingItem && $item->getHeadingLevel() === 2 ) {
135                $threadName = $item->getName();
136            } elseif ( $item instanceof CommentItem && $threadName !== null ) {
137                $comments[ $threadName ][ $item->getName() ][ $item->getId() ] = $item;
138            }
139        }
140        return $comments;
141    }
142
143    /**
144     * Get a list of all subscribable headings, grouped by name in case there are duplicates.
145     *
146     * @param ContentHeadingItem[] $items
147     * @return ContentHeadingItem[][]
148     */
149    private static function groupSubscribableHeadings( array $items ): array {
150        $headings = [];
151        foreach ( $items as $item ) {
152            if ( $item->isSubscribable() ) {
153                $headings[ $item->getName() ][ $item->getId() ] = $item;
154            }
155        }
156        return $headings;
157    }
158
159    /**
160     * Compare two lists of thread items, return those in $new but not $old.
161     *
162     * @param ContentThreadItem[][] $old
163     * @param ContentThreadItem[][] $new
164     * @return iterable<ContentThreadItem>
165     */
166    private static function findAddedItems( array $old, array $new ) {
167        foreach ( $new as $itemName => $nameNewItems ) {
168            // Usually, there will be 0 or 1 $nameNewItems, and 0 $nameOldItems,
169            // and $addedCount will be 0 or 1.
170            //
171            // But when multiple replies are added in one edit, or in multiple edits within the same
172            // minute, there may be more, and the complex logic below tries to make the best guess
173            // as to which items are actually new. See the 'multiple' and 'sametime' test cases.
174            //
175            $nameOldItems = $old[ $itemName ] ?? [];
176            $addedCount = count( $nameNewItems ) - count( $nameOldItems );
177
178            if ( $addedCount > 0 ) {
179                // For any name that occurs more times in new than old, report that many new items,
180                // preferring IDs that did not occur in old, then preferring items lower on the page.
181                foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) {
182                    if ( $addedCount > 0 && !isset( $nameOldItems[ $itemId ] ) ) {
183                        yield $newItem;
184                        $addedCount--;
185                    }
186                }
187                foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) {
188                    if ( $addedCount > 0 ) {
189                        yield $newItem;
190                        $addedCount--;
191                    }
192                }
193                Assert::postcondition( $addedCount === 0, 'Reported expected number of items' );
194            }
195        }
196    }
197
198    /**
199     * Helper for generateEventsForRevision(), separated out for easier testing.
200     *
201     * @param array &$events
202     * @param ContentThreadItemSet $oldItemSet
203     * @param ContentThreadItemSet $newItemSet
204     * @param RevisionRecord $newRevRecord
205     * @param PageIdentity $title
206     * @param UserIdentity $user
207     */
208    protected static function generateEventsFromItemSets(
209        array &$events,
210        ContentThreadItemSet $oldItemSet,
211        ContentThreadItemSet $newItemSet,
212        RevisionRecord $newRevRecord,
213        PageIdentity $title,
214        UserIdentity $user
215    ): void {
216        $newComments = static::groupCommentsByThreadAndName( $newItemSet->getThreadItems() );
217        $oldComments = static::groupCommentsByThreadAndName( $oldItemSet->getThreadItems() );
218        $addedComments = [];
219        foreach ( $newComments as $threadName => $threadNewComments ) {
220            $threadOldComments = $oldComments[ $threadName ] ?? [];
221            foreach ( static::findAddedItems( $threadOldComments, $threadNewComments ) as $newComment ) {
222                Assert::precondition( $newComment instanceof ContentCommentItem, 'Must be ContentCommentItem' );
223                $addedComments[] = $newComment;
224            }
225        }
226
227        $newHeadings = static::groupSubscribableHeadings( $newItemSet->getThreads() );
228        $oldHeadings = static::groupSubscribableHeadings( $oldItemSet->getThreads() );
229        $removedHeadings = [];
230        // Pass swapped parameters to findAddedItems() to find *removed* items
231        foreach ( static::findAddedItems( $newHeadings, $oldHeadings ) as $oldHeading ) {
232            Assert::precondition( $oldHeading instanceof ContentHeadingItem, 'Must be ContentHeadingItem' );
233            $removedHeadings[] = $oldHeading;
234        }
235
236        $mentionedUsers = [];
237        foreach ( $events as &$event ) {
238            if ( $event['type'] === 'mention' || $event['type'] === 'mention-summary' ) {
239                // Save mentioned users in our events, so that we can exclude them from our notification,
240                // to avoid duplicate notifications for a single comment.
241                // Array is keyed by user id so we can do a simple array merge.
242                $mentionedUsers += $event['extra']['mentioned-users'];
243            }
244
245            if ( count( $addedComments ) === 1 ) {
246                // If this edit was a new user talk message according to Echo,
247                // and we also found exactly one new comment,
248                // add some extra information to the edit-user-talk event.
249                if ( $event['type'] === 'edit-user-talk' ) {
250                    $event['extra'] += [
251                        'comment-id' => $addedComments[0]->getId(),
252                        'comment-name' => $addedComments[0]->getName(),
253                        'content' => $addedComments[0]->getBodyText( true ),
254                    ];
255                }
256
257                // Similarly for mentions.
258                // We don't handle 'content' in this case, as Echo makes its own snippets.
259                if ( $event['type'] === 'mention' ) {
260                    $event['extra'] += [
261                        'comment-id' => $addedComments[0]->getId(),
262                        'comment-name' => $addedComments[0]->getName(),
263                    ];
264                }
265            }
266        }
267
268        if ( $addedComments ) {
269            // It's a bit weird to do this here, in the middle of the hook handler for Echo. However:
270            // * Echo calls this from a PageSaveComplete hook handler as a DeferredUpdate,
271            //   which is exactly how we would do this otherwise
272            // * It allows us to reuse the generated comment trees without any annoying caching
273            static::addCommentChangeTag( $newRevRecord );
274            // For very similar reasons, we do logging here
275            static::logAddedComments( $addedComments, $newRevRecord, $title, $user );
276        }
277
278        foreach ( $addedComments as $newComment ) {
279            // Ignore comments by other users, e.g. in case of reverts or a discussion being moved.
280            // TODO: But what about someone signing another's comment?
281            if ( $newComment->getAuthor() !== $user->getName() ) {
282                continue;
283            }
284            // Ignore comments which are more than 10 minutes old, as this may be a user archiving
285            // their own comment. (T290803)
286            $revTimestamp = new DateTimeImmutable( $newRevRecord->getTimestamp() );
287            $threshold = $revTimestamp->sub( new DateInterval( 'PT10M' ) );
288            if ( $newComment->getTimestamp() <= $threshold ) {
289                continue;
290            }
291            $heading = $newComment->getSubscribableHeading();
292            if ( !$heading ) {
293                continue;
294            }
295            $events[] = [
296                'type' => 'dt-subscribed-new-comment',
297                'title' => $title,
298                'extra' => [
299                    'subscribed-comment-name' => $heading->getName(),
300                    'comment-id' => $newComment->getId(),
301                    'comment-name' => $newComment->getName(),
302                    'content' => $newComment->getBodyText( true ),
303                    'section-title' => $heading->getLinkableTitle(),
304                    'revid' => $newRevRecord->getId(),
305                    'mentioned-users' => $mentionedUsers,
306                ],
307                'agent' => $user,
308            ];
309
310            $titleForSubscriptions = Title::castFromPageIdentity( $title )->createFragmentTarget( $heading->getText() );
311            static::addAutoSubscription( $user, $titleForSubscriptions, $heading->getName() );
312        }
313
314        foreach ( $removedHeadings as $oldHeading ) {
315            $events[] = [
316                'type' => 'dt-removed-topic',
317                'title' => $title,
318                'extra' => [
319                    'subscribed-comment-name' => $oldHeading->getName(),
320                    'heading-id' => $oldHeading->getId(),
321                    'heading-name' => $oldHeading->getName(),
322                    'section-title' => $oldHeading->getLinkableTitle(),
323                    'revid' => $newRevRecord->getId(),
324                ],
325                'agent' => $user,
326            ];
327        }
328    }
329
330    /**
331     * Add our change tag for a revision that adds new comments.
332     *
333     * @param RevisionRecord $newRevRecord
334     */
335    protected static function addCommentChangeTag( RevisionRecord $newRevRecord ): void {
336        // Unclear if DeferredUpdates::addCallableUpdate() is needed,
337        // but every extension does it that way.
338        DeferredUpdates::addCallableUpdate( static function () use ( $newRevRecord ) {
339            ChangeTags::addTags( [ 'discussiontools-added-comment' ], null, $newRevRecord->getId() );
340        } );
341    }
342
343    /**
344     * Add an automatic subscription to the given item, assuming the user has automatic subscriptions
345     * enabled.
346     *
347     * @param UserIdentity $user
348     * @param Title $title
349     * @param string $itemName
350     */
351    protected static function addAutoSubscription( UserIdentity $user, Title $title, string $itemName ): void {
352        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
353
354        if (
355            $dtConfig->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'any' &&
356            HookUtils::shouldAddAutoSubscription( $user, $title )
357        ) {
358            $subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
359            $subscriptionStore->addAutoSubscriptionForUser( $user, $title, $itemName );
360        }
361    }
362
363    /**
364     * Return all users subscribed to a comment
365     *
366     * @param EchoEvent $event
367     * @param int $batchSize
368     * @return UserIdentity[]|Iterator<UserIdentity>
369     */
370    public static function locateSubscribedUsers( EchoEvent $event, $batchSize = 500 ) {
371        $commentName = $event->getExtraParam( 'subscribed-comment-name' );
372
373        $subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
374        $subscriptionItems = $subscriptionStore->getSubscriptionItemsForTopic(
375            $commentName,
376            [ SubscriptionStore::STATE_SUBSCRIBED, SubscriptionStore::STATE_AUTOSUBSCRIBED ]
377        );
378
379        // Update notified timestamps
380        $subscriptionStore->updateSubscriptionNotifiedTimestamp(
381            null,
382            $commentName
383        );
384
385        // TODD: Have this return an Iterator instead?
386        $users = array_map( static function ( SubscriptionItem $item ) {
387            return $item->getUserIdentity();
388        }, $subscriptionItems );
389
390        return $users;
391    }
392
393    /**
394     * Log stuff to EventLogging's Schema:TalkPageEvent
395     * If you don't have EventLogging installed, does nothing.
396     *
397     * @param array $addedComments
398     * @param RevisionRecord $newRevRecord
399     * @param PageIdentity $title
400     * @param UserIdentity $identity
401     * @return bool Whether events were logged
402     */
403    protected static function logAddedComments(
404        array $addedComments,
405        RevisionRecord $newRevRecord,
406        PageIdentity $title,
407        UserIdentity $identity
408    ): bool {
409        global $wgDTSchemaEditAttemptStepOversample, $wgDBname;
410        $context = RequestContext::getMain();
411        $request = $context->getRequest();
412        // We've reached here through Echo's post-save deferredupdate, which
413        // might either be after an API request from DiscussionTools or a
414        // regular POST from WikiEditor. Both should have this value snuck
415        // into their request if their session is being logged.
416        if ( !$request->getCheck( 'editingStatsId' ) ) {
417            return false;
418        }
419        $editingStatsId = $request->getVal( 'editingStatsId' );
420        $isDiscussionTools = $request->getCheck( 'dttags' );
421
422        $extensionRegistry = ExtensionRegistry::getInstance();
423        if ( !$extensionRegistry->isLoaded( 'EventLogging' ) ) {
424            return false;
425        }
426        $inSample = static::inEventSample( $editingStatsId );
427        $shouldOversample = ( $isDiscussionTools && $wgDTSchemaEditAttemptStepOversample ) || (
428                $extensionRegistry->isLoaded( 'WikimediaEvents' ) &&
429                // @phan-suppress-next-line PhanUndeclaredClassMethod
430                \WikimediaEvents\WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $context )
431            );
432        if ( !$inSample && !$shouldOversample ) {
433            return false;
434        }
435
436        $editTracker = MediaWikiServices::getInstance()
437            ->getUserEditTracker();
438
439        $commonData = [
440            '$schema' => '/analytics/mediawiki/talk_page_edit/1.1.0',
441            'action' => 'publish',
442            'session_id' => $editingStatsId,
443            'page_id' => $newRevRecord->getPageId(),
444            'page_namespace' => $title->getNamespace(),
445            'revision_id' => $newRevRecord->getId() ?: 0,
446            'performer' => [
447                // Note: we're logging the user who made the edit, not the user who's signed on the comment
448                'user_id' => $identity->getId(),
449                'user_edit_count' => $editTracker->getUserEditCount( $identity ) ?: 0,
450                // Retention-safe values:
451                'user_is_anonymous' => !$identity->isRegistered(),
452                'user_edit_count_bucket' => \UserBucketProvider::getUserEditCountBucket( $identity ) ?: 'N/A',
453            ],
454            'database' => $wgDBname,
455            // This is unreliable, but sufficient for our purposes; we
456            // mostly just want to see the difference between DT and
457            // everything-else:
458            'integration' => $isDiscussionTools ? 'discussiontools' : 'page',
459        ];
460
461        foreach ( $addedComments as $comment ) {
462            $heading = $comment->getSubscribableHeading();
463            $parent = $comment->getParent();
464            if ( !$heading || !$parent ) {
465                continue;
466            }
467            if ( $parent->getType() === 'heading' ) {
468                if ( count( $heading->getReplies() ) === 1 ) {
469                    // A new heading was added when this comment was created
470                    $component_type = 'topic';
471                } else {
472                    $component_type = 'comment';
473                }
474            } else {
475                $component_type = 'response';
476            }
477            EventLogging::submit( 'mediawiki.talk_page_edit', array_merge( $commonData, [
478                'component_type' => $component_type,
479                'topic_id' => $heading->getId(),
480                'comment_id' => $comment->getId(),
481                'comment_parent_id' => $parent->getId(),
482            ] ) );
483        }
484
485        return true;
486    }
487
488    /**
489     * Should the current session be sampled for EventLogging?
490     *
491     * @param string $sessionId
492     * @return bool Whether to sample the session
493     */
494    protected static function inEventSample( string $sessionId ): bool {
495        global $wgDTSchemaEditAttemptStepSamplingRate, $wgWMESchemaEditAttemptStepSamplingRate;
496        // Sample 6.25%
497        $samplingRate = 0.0625;
498        if ( isset( $wgDTSchemaEditAttemptStepSamplingRate ) ) {
499            $samplingRate = $wgDTSchemaEditAttemptStepSamplingRate;
500        }
501        if ( isset( $wgWMESchemaEditAttemptStepSamplingRate ) ) {
502            $samplingRate = $wgWMESchemaEditAttemptStepSamplingRate;
503        }
504        if ( $samplingRate === 0 ) {
505            return false;
506        }
507        $inSample = EventLogging::sessionInSample(
508            (int)( 1 / $samplingRate ), $sessionId
509        );
510        return $inSample;
511    }
512
513}