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