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