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