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