Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.90% covered (danger)
29.90%
29 / 97
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageSender
29.90% covered (danger)
29.90%
29 / 97
0.00% covered (danger)
0.00%
0 / 5
143.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 editPage
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
7.01
 addLQTThread
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 addFlowTopic
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 makeAPIRequest
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\MassMessage;
5
6use ApiMain;
7use ApiMessage;
8use ApiResult;
9use ApiUsageException;
10use ChangeTags;
11use FormatJson;
12use MediaWiki\Deferred\DeferredUpdates;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Permissions\PermissionManager;
15use MediaWiki\Request\DerivativeRequest;
16use MediaWiki\Title\Title;
17use MediaWiki\User\User;
18use RequestContext;
19use Wikimedia\ScopedCallback;
20
21/**
22 * Post messages on target pages
23 * @author Abijeet Patro
24 * @since 2022.08
25 * @license GPL-2.0-or-later
26 */
27class MessageSender {
28    /** @var PermissionManager */
29    private $permissionManager;
30    /** @var callable|null */
31    private $failureCallback;
32
33    /**
34     * @param PermissionManager $permissionManager
35     * @param callable|null $failureCallback
36     */
37    public function __construct(
38        PermissionManager $permissionManager,
39        ?callable $failureCallback
40    ) {
41        $this->permissionManager = $permissionManager;
42        $this->failureCallback = $failureCallback;
43    }
44
45    /**
46     * @param Title $target
47     * @param string $message
48     * @param string $subject
49     * @param User $user
50     * @param string $dedupeHash
51     * @return bool
52     */
53    public function editPage(
54        Title $target,
55        string $message,
56        string $subject,
57        User $user,
58        string $dedupeHash
59    ): bool {
60        $params = [
61            'action' => 'edit',
62            'title' => $target->getPrefixedText(),
63            'section' => 'new',
64            'summary' => $subject,
65            'text' => $message,
66            'notminor' => true,
67            'token' => $user->getEditToken()
68        ];
69
70        if ( $target->inNamespace( NS_USER_TALK ) ) {
71            $params['bot'] = true;
72        }
73
74        $result = $this->makeAPIRequest( $params, $user );
75        if ( $result ) {
76            // Apply change tag if the edit succeeded
77            $resultData = $result->getResultData();
78            if ( !isset( $resultData['edit']['result'] )
79                || $resultData['edit']['result'] !== 'Success'
80            ) {
81                // job should retry the edit
82                return false;
83            }
84            if ( !isset( $resultData['edit']['nochange'] )
85                && $resultData['edit']['newrevid']
86            ) {
87                $revId = $resultData['edit']['newrevid'];
88                DeferredUpdates::addCallableUpdate( static function () use ( $revId, $dedupeHash ) {
89                    ChangeTags::addTags(
90                        'massmessage-delivery',
91                        null,
92                        $revId,
93                        null,
94                        FormatJson::encode( [ 'dedupe_hash' => $dedupeHash ] ),
95                    );
96                } );
97            }
98            return true;
99        }
100        return false;
101    }
102
103    /**
104     * @param Title $target
105     * @param string $message
106     * @param string $subject
107     * @param User $user
108     * @return bool
109     */
110    public function addLQTThread(
111        Title $target,
112        string $message,
113        string $subject,
114        User $user
115    ): bool {
116        $params = [
117            'action' => 'threadaction',
118            'threadaction' => 'newthread',
119            'talkpage' => $target,
120            'subject' => $subject,
121            'text' => $message,
122            'token' => $user->getEditToken()
123            // LQT will automatically mark the edit as bot if we're a bot, so don't set here
124        ];
125
126        return (bool)$this->makeAPIRequest( $params, $user );
127    }
128
129    /**
130     * @param Title $target
131     * @param string $message
132     * @param string $subject
133     * @param User $user
134     * @return bool
135     */
136    public function addFlowTopic(
137        Title $target,
138        string $message,
139        string $subject,
140        User $user
141    ): bool {
142        $params = [
143            'action' => 'flow',
144            'page' => $target->getPrefixedText(),
145            'submodule' => 'new-topic',
146            'nttopic' => $subject,
147            'ntcontent' => $message,
148            'token' => $user->getEditToken(),
149        ];
150
151        return (bool)$this->makeAPIRequest( $params, $user );
152    }
153
154    /**
155     * Construct and make an API request based on the given params and return the results.
156     *
157     * @param array $params
158     * @param User $ourUser
159     * @return ?ApiResult
160     */
161    private function makeAPIRequest( array $params, User $ourUser ): ?ApiResult {
162        // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgUser
163        global $wgUser, $wgRequest;
164
165        // Add our hook functions to make the MassMessage user IP block-exempt and email confirmed.
166        // Done here so that it's not unnecessarily called on every page load.
167        $lock = $this->permissionManager->addTemporaryUserRights( $ourUser, [ 'ipblock-exempt' ] );
168        $hookScope = MediaWikiServices::getInstance()->getHookContainer()->scopedRegister(
169            'EmailConfirmed',
170            [ MassMessageHooks::class, 'onEmailConfirmed' ]
171        );
172
173        $oldRequest = $wgRequest;
174        $oldUser = $wgUser;
175
176        $wgRequest = new DerivativeRequest(
177            $wgRequest,
178            $params,
179            // was posted?
180            true
181        );
182        // New user objects will use $wgRequest, so we set that
183        // to our DerivativeRequest, so we don't run into any issues.
184        $wgUser = $ourUser;
185        $context = RequestContext::getMain();
186        // All further internal API requests will use the main
187        // RequestContext, so setting it here will fix it for
188        // all other internal uses, like how LQT does
189        $oldCUser = $context->getUser();
190        $oldCRequest = $context->getRequest();
191        $context->setUser( $ourUser );
192        $context->setRequest( $wgRequest );
193
194        $api = new ApiMain(
195            $wgRequest,
196            // enable write?
197            true
198        );
199        try {
200            $attemptCount = 0;
201            while ( true ) {
202                try {
203                    $api->execute();
204                    // Continue after the while block if the API request succeeds
205                    break;
206                } catch ( ApiUsageException $e ) {
207                    $attemptCount++;
208                    $isEditConflict = false;
209                    foreach ( $e->getStatusValue()->getErrors() as $error ) {
210                        if ( ApiMessage::create( $error )->getApiCode() === 'editconflict' ) {
211                            $isEditConflict = true;
212                            break;
213                        }
214                    }
215                    // If the failure is not caused by an edit conflict or if there
216                    // have been too many failures, log the (first) error and continue
217                    // execution. Otherwise retry the request.
218                    if ( !$isEditConflict || $attemptCount >= 5 ) {
219                        foreach ( $e->getStatusValue()->getErrors() as $error ) {
220                            if ( $this->failureCallback ) {
221                                call_user_func( $this->failureCallback, ApiMessage::create( $error )->getApiCode() );
222                            }
223                            break;
224                        }
225                        return null;
226                    }
227                }
228            }
229            return $api->getResult();
230        } finally {
231            // Cleanup all the stuff we polluted
232            ScopedCallback::consume( $hookScope );
233            $context->setUser( $oldCUser );
234            $context->setRequest( $oldCRequest );
235            $wgUser = $oldUser;
236            $wgRequest = $oldRequest;
237        }
238    }
239}