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