Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
19.43% |
34 / 175 |
|
13.33% |
2 / 15 |
CRAP | |
0.00% |
0 / 1 |
MassMessageJob | |
19.43% |
34 / 175 |
|
13.33% |
2 / 15 |
1253.11 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
run | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getUser | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
isOptedOut | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
normalizeTitle | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
logLocalSkip | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
logLocalFailure | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
logToDebugLog | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
sendMessage | |
79.31% |
23 / 29 |
|
0.00% |
0 / 1 |
8.57 | |||
editPage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addLQTThread | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addFlowTopic | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPageMessageDetails | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
canDeliverMessage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
deliverMessage | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
72 |
1 | <?php |
2 | |
3 | namespace MediaWiki\MassMessage\Job; |
4 | |
5 | use ExtensionRegistry; |
6 | use Job; |
7 | use LqtDispatch; |
8 | use ManualLogEntry; |
9 | use MediaWiki\MassMessage\DedupeHelper; |
10 | use MediaWiki\MassMessage\Job\Hooks\HookRunner; |
11 | use MediaWiki\MassMessage\LanguageAwareText; |
12 | use MediaWiki\MassMessage\MassMessage; |
13 | use MediaWiki\MassMessage\MessageBuilder; |
14 | use MediaWiki\MassMessage\MessageSender; |
15 | use MediaWiki\MassMessage\PageMessage\PageMessageBuilderResult; |
16 | use MediaWiki\MassMessage\Services; |
17 | use MediaWiki\MassMessage\UrlHelper; |
18 | use MediaWiki\MediaWikiServices; |
19 | use MediaWiki\Title\Title; |
20 | use MediaWiki\User\CentralId\CentralIdLookup; |
21 | use MediaWiki\User\User; |
22 | use MediaWiki\WikiMap\WikiMap; |
23 | |
24 | /** |
25 | * Job Queue class to send a message to a user. |
26 | * |
27 | * Based on code from TranslationNotifications |
28 | * https://mediawiki.org/wiki/Extension:TranslationNotifications |
29 | * |
30 | * @file |
31 | * @ingroup JobQueue |
32 | * @author Kunal Mehta |
33 | * @license GPL-2.0-or-later |
34 | */ |
35 | |
36 | class MassMessageJob extends Job { |
37 | /** |
38 | * @var bool Whether to use sender account (if possible) |
39 | * TODO: Expose this as a configurable option (T71954) |
40 | */ |
41 | private $useSenderUser = false; |
42 | /** @var MessageSender|null */ |
43 | private $messageSender; |
44 | /** @var HookRunner */ |
45 | private $hookRunner; |
46 | |
47 | /** |
48 | * @param Title $title |
49 | * @param array $params |
50 | */ |
51 | public function __construct( Title $title, array $params ) { |
52 | parent::__construct( 'MassMessageJob', $title, $params ); |
53 | $this->removeDuplicates = true; |
54 | // Create a fresh Title object so namespaces are evaluated |
55 | // in the context of the target site. See T59464. |
56 | // Note that jobs created previously might not have a |
57 | // title param, so check for that. |
58 | if ( isset( $params['title'] ) ) { |
59 | $this->title = Title::newFromText( $params['title'] ); |
60 | } else { |
61 | $this->title = $title; |
62 | } |
63 | |
64 | $this->hookRunner = new HookRunner( |
65 | MediaWikiServices::getInstance()->getHookContainer() |
66 | ); |
67 | } |
68 | |
69 | /** |
70 | * Execute the job. |
71 | * |
72 | * @return bool |
73 | */ |
74 | public function run() { |
75 | $this->messageSender = new MessageSender( |
76 | MediaWikiServices::getInstance()->getPermissionManager(), |
77 | function ( string $msg ) { |
78 | $this->logLocalFailure( $msg ); |
79 | } |
80 | ); |
81 | |
82 | $status = $this->sendMessage(); |
83 | if ( !$status ) { |
84 | $this->setLastError( 'There was an error while sending the message.' ); |
85 | return false; |
86 | } |
87 | |
88 | return true; |
89 | } |
90 | |
91 | /** |
92 | * @return User |
93 | */ |
94 | protected function getUser() { |
95 | if ( $this->useSenderUser && isset( $this->params['userId'] ) ) { |
96 | $services = MediaWikiServices::getInstance(); |
97 | $user = $services |
98 | ->getCentralIdLookup() |
99 | ->localUserFromCentralId( |
100 | $this->params['userId'], |
101 | CentralIdLookup::AUDIENCE_RAW |
102 | ); |
103 | if ( $user ) { |
104 | return $services->getUserFactory()->newFromUserIdentity( $user ); |
105 | } |
106 | } |
107 | |
108 | return MassMessage::getMessengerUser(); |
109 | } |
110 | |
111 | /** |
112 | * Checks whether the target page is in an opt-out category. |
113 | * |
114 | * @param Title $title |
115 | * @return bool |
116 | */ |
117 | public function isOptedOut( Title $title ) { |
118 | $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); |
119 | $categories = $wikipage->getCategories(); |
120 | $category = Title::makeTitle( |
121 | NS_CATEGORY, |
122 | wfMessage( 'massmessage-optout-category' )->inContentLanguage()->text() |
123 | ); |
124 | foreach ( $categories as $cat ) { |
125 | if ( $category->equals( $cat ) ) { |
126 | return true; |
127 | } |
128 | } |
129 | |
130 | return false; |
131 | } |
132 | |
133 | /** |
134 | * Normalizes the title according to $wgNamespacesToConvert, $wgNamespacesToPostIn |
135 | * and $wgAllowlistedMassMessageTargets. |
136 | * |
137 | * @param Title $title |
138 | * @return Title|null null if we shouldn't post on that title |
139 | */ |
140 | protected function normalizeTitle( Title $title ) { |
141 | $mainConfig = MediaWikiServices::getInstance()->getMainConfig(); |
142 | $namespacesToConvert = $mainConfig->get( 'NamespacesToConvert' ); |
143 | if ( isset( $namespacesToConvert[$title->getNamespace()] ) ) { |
144 | $title = Title::makeTitle( $namespacesToConvert[$title->getNamespace()], $title->getText() ); |
145 | } |
146 | // Try to follow redirects |
147 | $title = UrlHelper::followRedirect( $title ) ?: $title; |
148 | if ( |
149 | !$title->isTalkPage() && |
150 | !in_array( $title->getNamespace(), $mainConfig->get( 'NamespacesToPostIn' ) ) && |
151 | !in_array( $title->getId(), $mainConfig->get( 'AllowlistedMassMessageTargets' ) ) |
152 | ) { |
153 | $this->logLocalSkip( 'skipbadns' ); |
154 | $title = null; |
155 | } |
156 | |
157 | return $title; |
158 | } |
159 | |
160 | /** |
161 | * Log any skips on the target site |
162 | * |
163 | * @param string $reason log subtype |
164 | */ |
165 | protected function logLocalSkip( $reason ) { |
166 | $logEntry = new ManualLogEntry( 'massmessage', $reason ); |
167 | $logEntry->setPerformer( $this->getUser() ); |
168 | $logEntry->setTarget( $this->title ); |
169 | $logEntry->setParameters( [ |
170 | '4::subject' => $this->params['subject'] |
171 | ] ); |
172 | |
173 | $logid = $logEntry->insert(); |
174 | $logEntry->publish( $logid ); |
175 | } |
176 | |
177 | /** |
178 | * Log any message failures on the target site. |
179 | * |
180 | * @param string $reason |
181 | */ |
182 | protected function logLocalFailure( $reason ) { |
183 | $logEntry = new ManualLogEntry( 'massmessage', 'failure' ); |
184 | $logEntry->setPerformer( $this->getUser() ); |
185 | $logEntry->setTarget( $this->title ); |
186 | $logEntry->setParameters( [ |
187 | '4::subject' => $this->params['subject'], |
188 | '5::reason' => $reason, |
189 | ] ); |
190 | |
191 | $logid = $logEntry->insert(); |
192 | $logEntry->publish( $logid ); |
193 | |
194 | // stick it in the debug log |
195 | $this->logToDebugLog( $reason ); |
196 | } |
197 | |
198 | /** |
199 | * Log metadata about a delivery to the debug log with the given reason. |
200 | * |
201 | * @param string $reason |
202 | */ |
203 | protected function logToDebugLog( $reason ) { |
204 | $text = 'Target: ' . $this->title->getPrefixedText(); |
205 | $text .= ' Subject: ' . $this->params['subject']; |
206 | $text .= ' Reason: ' . $reason; |
207 | $text .= ' Origin Wiki: ' . ( $this->params['originWiki'] ?? 'N/A' ); |
208 | wfDebugLog( 'MassMessage', $text ); |
209 | } |
210 | |
211 | /** |
212 | * Send a message to a user. |
213 | * Modified from the TranslationNotification extension. |
214 | * |
215 | * @return bool |
216 | */ |
217 | protected function sendMessage(): bool { |
218 | $title = $this->normalizeTitle( $this->title ); |
219 | if ( $title === null ) { |
220 | // Skip it |
221 | return true; |
222 | } |
223 | |
224 | $this->title = $title; |
225 | |
226 | if ( !$this->canDeliverMessage( $title ) ) { |
227 | return true; |
228 | } |
229 | |
230 | $pageMessageBuilderResult = $this->getPageMessageDetails(); |
231 | if ( $pageMessageBuilderResult && !$pageMessageBuilderResult->isOK() ) { |
232 | $this->logLocalFailure( |
233 | $pageMessageBuilderResult->getResultMessage()->text() |
234 | ); |
235 | return true; |
236 | } |
237 | |
238 | $subject = $this->params['subject'] ?? ''; |
239 | $message = $this->params['message'] ?? ''; |
240 | $pageSubject = $pageMessageBuilderResult ? $pageMessageBuilderResult->getPageSubject() : null; |
241 | $pageMessage = $pageMessageBuilderResult ? $pageMessageBuilderResult->getPageMessage() : null; |
242 | |
243 | $dedupeHash = DedupeHelper::getDedupeHash( $subject, $message, $pageSubject, $pageMessage ); |
244 | if ( DedupeHelper::hasRecentlyDeliveredDuplicate( $this->title, $dedupeHash ) ) { |
245 | $this->logToDebugLog( 'Delivery skipped because it is a duplicate of a recently delivered message.' ); |
246 | return true; |
247 | } |
248 | |
249 | return $this->deliverMessage( |
250 | $title, |
251 | $subject, |
252 | $message, |
253 | $pageSubject, |
254 | $pageMessage, |
255 | $this->params['comment'], |
256 | $dedupeHash, |
257 | ); |
258 | } |
259 | |
260 | /** |
261 | * @param string $text |
262 | * @param string $subject |
263 | * @param string $dedupeHash |
264 | * @return bool |
265 | */ |
266 | protected function editPage( string $text, string $subject, string $dedupeHash ): bool { |
267 | return $this->messageSender->editPage( $this->title, $text, $subject, $this->getUser(), $dedupeHash ); |
268 | } |
269 | |
270 | /** |
271 | * @param string $text |
272 | * @param string $subject |
273 | * @return bool |
274 | */ |
275 | protected function addLQTThread( string $text, string $subject ): bool { |
276 | return $this->messageSender->addLQTThread( $this->title, $text, $subject, $this->getUser() ); |
277 | } |
278 | |
279 | /** |
280 | * @param string $text |
281 | * @param string $subject |
282 | * @return bool |
283 | */ |
284 | protected function addFlowTopic( string $text, string $subject ): bool { |
285 | return $this->messageSender->addFlowTopic( $this->title, $text, $subject, $this->getUser() ); |
286 | } |
287 | |
288 | /** |
289 | * Fetch content from the page and the necessary sections |
290 | * |
291 | * @return PageMessageBuilderResult|null |
292 | */ |
293 | private function getPageMessageDetails(): ?PageMessageBuilderResult { |
294 | $titleStr = $this->params['pageMessageTitle'] ?? null; |
295 | $isSourceTranslationPage = $this->params['isSourceTranslationPage'] ?? false; |
296 | $pageMessageSection = $this->params['page-message-section'] ?? null; |
297 | $pageSubjectSection = $this->params['page-subject-section'] ?? null; |
298 | |
299 | if ( !$titleStr ) { |
300 | return null; |
301 | } |
302 | |
303 | $originWiki = $this->params['originWiki'] ?? WikiMap::getCurrentWikiId(); |
304 | $pageMessageBuilder = Services::getInstance()->getPageMessageBuilder(); |
305 | if ( $isSourceTranslationPage ) { |
306 | $pageMessageBuilderResult = $pageMessageBuilder->getContentWithFallback( |
307 | $titleStr, |
308 | $this->title->getPageLanguage()->getCode(), |
309 | $this->params['translationPageSourceLanguage'] ?? '', |
310 | $pageMessageSection, |
311 | $pageSubjectSection, |
312 | $originWiki |
313 | ); |
314 | } else { |
315 | $pageMessageBuilderResult = $pageMessageBuilder->getContent( |
316 | $titleStr, $pageMessageSection, $pageSubjectSection, $originWiki |
317 | ); |
318 | } |
319 | |
320 | return $pageMessageBuilderResult; |
321 | } |
322 | |
323 | /** |
324 | * Check if it's OK to deliver the message to the client |
325 | * @param Title $title |
326 | * @return bool |
327 | */ |
328 | private function canDeliverMessage( Title $title ): bool { |
329 | if ( $this->isOptedOut( $this->title ) ) { |
330 | $this->logLocalSkip( 'skipoptout' ); |
331 | // Oh well. |
332 | return false; |
333 | } |
334 | |
335 | // If we're sending to a User:/User talk: page, make sure the user exists. |
336 | // Redirects are automatically followed in getLocalTargets |
337 | if ( |
338 | $title->inNamespaces( NS_USER, NS_USER_TALK ) && |
339 | !in_array( |
340 | $title->getId(), |
341 | MediaWikiServices::getInstance()->getMainConfig()->get( 'AllowlistedMassMessageTargets' ) |
342 | ) |
343 | ) { |
344 | $user = User::newFromName( $title->getRootText() ); |
345 | if ( !$user || !$user->isNamed() ) { |
346 | // Don't send to anonymous and temporary users |
347 | $this->logLocalSkip( 'skipnouser' ); |
348 | return false; |
349 | } |
350 | } |
351 | |
352 | return true; |
353 | } |
354 | |
355 | /** |
356 | * Deliver the message to the target page after making tweaks to the message based on |
357 | * the discussion system of target page. |
358 | * @param Title $targetPage |
359 | * @param string $subject |
360 | * @param string $message |
361 | * @param LanguageAwareText|null $pageSubject |
362 | * @param LanguageAwareText|null $pageMessage |
363 | * @param array $comment |
364 | * @param string $dedupeHash |
365 | * @return bool |
366 | */ |
367 | private function deliverMessage( |
368 | Title $targetPage, |
369 | string $subject, |
370 | string $message, |
371 | ?LanguageAwareText $pageSubject, |
372 | ?LanguageAwareText $pageMessage, |
373 | array $comment, |
374 | string $dedupeHash |
375 | ): bool { |
376 | $targetLanguage = $targetPage->getPageLanguage(); |
377 | $messageBuilder = new MessageBuilder(); |
378 | |
379 | $isLqtThreads = ExtensionRegistry::getInstance()->isLoaded( 'Liquid Threads' ) |
380 | && LqtDispatch::isLqtPage( $targetPage ); |
381 | $isStructuredDiscussion = $targetPage->hasContentModel( 'flow-board' ) |
382 | // But it can't be a Topic: page, see bug 71196 |
383 | && defined( 'NS_TOPIC' ) |
384 | && !$targetPage->inNamespace( NS_TOPIC ); |
385 | |
386 | $failureCallback = function ( $msg ) { |
387 | $this->logLocalFailure( $msg ); |
388 | }; |
389 | // Allow hooks to override processing |
390 | if ( !$this->hookRunner->onMassMessageJobBeforeMessageSent( |
391 | $failureCallback, |
392 | $targetPage, |
393 | $subject, |
394 | $message, |
395 | $pageSubject, |
396 | $pageMessage, |
397 | $comment |
398 | ) ) { |
399 | // Hook returning false means that the hook handler |
400 | // sent the message and is asking us to not send the message ourselves. |
401 | // We still return true since the hook did successfully send the message. |
402 | return true; |
403 | } |
404 | |
405 | // If the page is using a different discussion system, handle it specially |
406 | if ( $isLqtThreads || $isStructuredDiscussion ) { |
407 | $subject = $messageBuilder->buildPlaintextSubject( $subject, $pageSubject ); |
408 | $message = $messageBuilder->buildMessage( |
409 | $messageBuilder->stripTildes( $message ), |
410 | $pageMessage, |
411 | $targetLanguage, |
412 | $comment |
413 | ); |
414 | |
415 | if ( $isLqtThreads ) { |
416 | return $this->addLQTThread( $message, $subject ); |
417 | } else { |
418 | return $this->addFlowTopic( $message, $subject ); |
419 | } |
420 | } |
421 | $subject = $messageBuilder->buildSubject( $subject, $pageSubject, $targetLanguage ); |
422 | $message = $messageBuilder->buildMessage( $message, $pageMessage, $targetLanguage, $comment ); |
423 | return $this->editPage( $message, $subject, $dedupeHash ); |
424 | } |
425 | } |