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