Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.43% covered (danger)
19.43%
34 / 175
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
MassMessageJob
19.43% covered (danger)
19.43%
34 / 175
13.33% covered (danger)
13.33%
2 / 15
1253.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 run
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getUser
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 isOptedOut
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 normalizeTitle
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 logLocalSkip
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 logLocalFailure
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 logToDebugLog
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 sendMessage
79.31% covered (warning)
79.31%
23 / 29
0.00% covered (danger)
0.00%
0 / 1
8.57
 editPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addLQTThread
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addFlowTopic
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageMessageDetails
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 canDeliverMessage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 deliverMessage
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace MediaWiki\MassMessage\Job;
4
5use ExtensionRegistry;
6use Job;
7use LqtDispatch;
8use ManualLogEntry;
9use MediaWiki\MassMessage\DedupeHelper;
10use MediaWiki\MassMessage\Job\Hooks\HookRunner;
11use MediaWiki\MassMessage\LanguageAwareText;
12use MediaWiki\MassMessage\MassMessage;
13use MediaWiki\MassMessage\MessageBuilder;
14use MediaWiki\MassMessage\MessageSender;
15use MediaWiki\MassMessage\PageMessage\PageMessageBuilderResult;
16use MediaWiki\MassMessage\Services;
17use MediaWiki\MassMessage\UrlHelper;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Title\Title;
20use MediaWiki\User\CentralId\CentralIdLookup;
21use MediaWiki\User\User;
22use 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
36class 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}