1 | <?php |
||
2 | /* |
||
3 | * This file is part of dispositif/wikibot application (@github) |
||
4 | * 2019-2023 © Philippe M./Irønie <[email protected]> |
||
5 | * For the full copyright and MIT license information, view the license file. |
||
6 | */ |
||
7 | |||
8 | declare(strict_types=1); |
||
9 | |||
10 | namespace App\Application; |
||
11 | |||
12 | use App\Application\InfrastructurePorts\SMSInterface; |
||
13 | use App\Domain\Exceptions\ConfigException; |
||
14 | use App\Domain\Exceptions\StopActionException; |
||
15 | use App\Domain\Utils\WikiTextUtil; |
||
16 | use App\Infrastructure\Monitor\NullLogger; |
||
17 | use DateInterval; |
||
18 | use DateTime; |
||
19 | use DateTimeImmutable; |
||
20 | use DomainException; |
||
21 | use Mediawiki\Api\MediawikiFactory; |
||
22 | use Mediawiki\Api\UsageException; |
||
23 | use Psr\Log\LoggerInterface; |
||
24 | use Throwable; |
||
25 | |||
26 | /** |
||
27 | * Define wiki configuration of the bot. |
||
28 | * See also .env file for parameters. |
||
29 | * Page/Edit/Summary status are defined in Application/.../PageWorkStatus or Domain/OptiStatus |
||
30 | */ |
||
31 | class WikiBotConfig |
||
32 | { |
||
33 | public const VERSION = '2.2'; |
||
34 | public const WATCHPAGE_FILENAME = __DIR__ . '/resources/watch_pages.json'; |
||
35 | public const EXIT_ON_CHECK_WATCHPAGE = false; |
||
36 | // do not stop if they play with {stop} on bot talk page |
||
37 | public const BLACKLIST_EDITOR = ['OrlodrimBot']; |
||
38 | // Use that timers config instead of worker config ? |
||
39 | public const BOT_FLAG = false; |
||
40 | public const MODE_AUTO = false; |
||
41 | public const EXIT_ON_WIKIMESSAGE = true; |
||
42 | public const EDIT_LAPS = 20; |
||
43 | public const EDIT_LAPS_MANUAL = 20; |
||
44 | public const EDIT_LAPS_AUTOBOT = 60; |
||
45 | public const EDIT_LAPS_FLAGBOT = 8; |
||
46 | public const TALK_STOP_CHECK_INTERVAL = 'PT2M'; |
||
47 | public const TALK_PAGE_PREFIX = 'Discussion_utilisateur:'; |
||
48 | public const SLEEP_BEFORE_STOP_TALKPAGE = 30; |
||
49 | public const DELAY_MINUTES_AFTER_HUMAN_EDIT = 15; |
||
50 | |||
51 | protected const BOT_DAY = '2306'; // 23 june |
||
52 | |||
53 | protected string $taskName = 'Amélioration'; |
||
54 | /** |
||
55 | * @var LoggerInterface |
||
56 | */ |
||
57 | protected $log; |
||
58 | /** |
||
59 | * @var DateTimeImmutable |
||
60 | */ |
||
61 | protected $lastCheckStopDate; |
||
62 | protected SMSInterface|null $SMSClient; |
||
63 | protected $mediawikiFactory; |
||
64 | protected ?string $gitCommitHash = null; |
||
65 | |||
66 | public function __construct(MediawikiFactory $mediawikiFactory, ?LoggerInterface $logger = null, ?SMSInterface $SMSClient = null) |
||
67 | { |
||
68 | $this->log = $logger ?? new NullLogger(); |
||
69 | ini_set('user_agent', getenv('USER_AGENT')); |
||
70 | $this->SMSClient = $SMSClient; |
||
71 | $this->mediawikiFactory = $mediawikiFactory; |
||
72 | } |
||
73 | |||
74 | /** |
||
75 | * Detect wiki-templates restricting the edition on a frwiki page. |
||
76 | */ |
||
77 | public static function isEditionTemporaryRestrictedOnWiki(?string $text, ?string $botName = null): bool |
||
78 | { |
||
79 | return empty($text) |
||
80 | || preg_match('#{{Formation#i', $text) > 0 |
||
81 | || preg_match('#{{En travaux#i', $text) > 0 |
||
82 | || preg_match('#{{En cours#i', $text) > 0 |
||
83 | || preg_match('#{{Protection#i', $text) > 0 |
||
84 | || preg_match('#\{\{(R3R|Règle des 3 révocations|travaux|en travaux|en cours|formation)#i', $text) > 0 |
||
85 | || self::isNoBotTag($text, $botName); |
||
86 | } |
||
87 | |||
88 | /** |
||
89 | * Detect {{nobots}}, {{bots|deny=all}}, {{bots|deny=MyBot,BobBot}}. |
||
90 | * Relevant out of the "main" wiki-namespace (talk pages, etc). |
||
91 | */ |
||
92 | 1 | protected static function isNoBotTag(string $text, ?string $botName = null): bool |
|
93 | { |
||
94 | 1 | $text = WikiTextUtil::removeHTMLcomments($text); |
|
95 | $botName = $botName ?: self::getBotName(); |
||
96 | $denyReg = (empty($botName)) ? '' : |
||
97 | 1 | '|\{\{bots ?\| ?(optout|deny)\=[^\}]*' . preg_quote($botName, '#') . '[^\}]*\}\}'; |
|
98 | 1 | return preg_match('#({{nobots}}|{{bots ?\| ?(optout|deny) ?= ?all ?}}' . $denyReg . ')#i', $text) > 0; |
|
99 | 1 | } |
|
100 | |||
101 | /** |
||
102 | * @throws ConfigException |
||
103 | */ |
||
104 | public static function getBotName(): string |
||
105 | 1 | { |
|
106 | if (empty(getenv('BOT_NAME'))) { |
||
107 | throw new ConfigException('BOT_NAME is not defined.'); |
||
108 | } |
||
109 | return getenv('BOT_NAME') ?? ''; |
||
110 | } |
||
111 | |||
112 | protected static function getBotOwner() |
||
113 | { |
||
114 | return getenv('BOT_OWNER'); |
||
115 | } |
||
116 | |||
117 | public function getTaskName(): string |
||
118 | { |
||
119 | return $this->taskName; |
||
120 | } |
||
121 | |||
122 | public function setTaskName(string $taskName): WikiBotConfig |
||
123 | { |
||
124 | $this->taskName = $taskName; |
||
125 | return $this; |
||
126 | } |
||
127 | |||
128 | public function getCurrentGitCommitHash(): ?string |
||
129 | { |
||
130 | if ($this->gitCommitHash) { |
||
131 | return $this->gitCommitHash; |
||
132 | } |
||
133 | $path = __DIR__ . '/../../.git/'; |
||
134 | if (!file_exists($path)) { |
||
135 | return null; |
||
136 | } |
||
137 | $head = trim(substr(file_get_contents($path . 'HEAD'), 4)); |
||
138 | $hash = trim(file_get_contents(sprintf($path . $head))); |
||
139 | $this->gitCommitHash = $hash; // cached |
||
140 | |||
141 | return $hash; |
||
142 | } |
||
143 | |||
144 | public function getLogger(): LoggerInterface |
||
145 | { |
||
146 | return $this->log; |
||
147 | } |
||
148 | |||
149 | /** |
||
150 | * Throws Exception if "{{stop}}" or "STOP" on talk page. |
||
151 | * @throws StopActionException |
||
152 | */ |
||
153 | public function checkStopOnTalkpageOrException(?bool $botTalk = false): void |
||
154 | { |
||
155 | if ($this->isLastCheckStopDateRecent()) { |
||
156 | return; |
||
157 | } |
||
158 | $this->sleepAndExitOnBotDay(); |
||
159 | |||
160 | // don't catch Exception (stop process if error) |
||
161 | $pageAction = $this->getWikiBotPageAction(); |
||
162 | $text = $pageAction->getText() ?? ''; |
||
163 | $lastEditor = $pageAction->getLastEditor() ?? 'unknown'; |
||
164 | |||
165 | if (preg_match('#({{stop}}|{{Stop}}|STOP)#', $text) > 0) { |
||
166 | echo date('Y-m-d H:i'); |
||
167 | echo sprintf("\n*** STOP ON TALK PAGE BY %s ***\n\n", $lastEditor); |
||
168 | sleep(self::SLEEP_BEFORE_STOP_TALKPAGE); |
||
169 | |||
170 | $this->sendSMSandFunnyTalk($lastEditor, $botTalk); |
||
171 | |||
172 | throw new StopActionException(); |
||
173 | } |
||
174 | |||
175 | $this->lastCheckStopDate = new DateTimeImmutable(); |
||
176 | } |
||
177 | |||
178 | /** |
||
179 | * Exit script on annual bot day. |
||
180 | */ |
||
181 | public function sleepAndExitOnBotDay(): void |
||
182 | { |
||
183 | if (date('dm') === self::BOT_DAY) { |
||
184 | echo "BOT DAY ! Sleep before exit. \n"; |
||
185 | sleep(60 * 60); |
||
186 | exit; |
||
0 ignored issues
–
show
|
|||
187 | } |
||
188 | } |
||
189 | |||
190 | protected function isLastCheckStopDateRecent(): bool |
||
191 | { |
||
192 | $now = new DateTimeImmutable(); |
||
193 | $stopInterval = new DateInterval(self::TALK_STOP_CHECK_INTERVAL); |
||
194 | |||
195 | return $this->lastCheckStopDate instanceof DateTimeImmutable |
||
196 | && $now < DateTime::createFromImmutable($this->lastCheckStopDate)->add($stopInterval); |
||
197 | } |
||
198 | |||
199 | /** |
||
200 | * @throws UsageException |
||
201 | */ |
||
202 | protected function getWikiBotPageAction(): WikiPageAction |
||
203 | { |
||
204 | return new WikiPageAction($this->mediawikiFactory, $this->getBotTalkPageTitle()); |
||
205 | } |
||
206 | |||
207 | protected function getBotTalkPageTitle(): string |
||
208 | { |
||
209 | return self::TALK_PAGE_PREFIX . $this::getBotName(); |
||
210 | } |
||
211 | |||
212 | protected function sendSMSandFunnyTalk(string $lastEditor, ?bool $botTalk): void |
||
213 | { |
||
214 | $this->sendSMS($lastEditor); |
||
215 | |||
216 | if ($botTalk) { |
||
217 | $this->talkWithBot(); |
||
218 | } |
||
219 | } |
||
220 | |||
221 | protected function sendSMS(string $lastEditor): bool |
||
222 | { |
||
223 | if ($this->SMSClient instanceof SMSInterface) { |
||
224 | try { |
||
225 | return $this->SMSClient->send(sprintf('%s {stop} by %s', $this::getBotName(), $lastEditor)); |
||
226 | } catch (Throwable) { |
||
227 | return false; |
||
228 | } |
||
229 | } |
||
230 | |||
231 | return false; |
||
232 | } |
||
233 | |||
234 | protected function talkWithBot(): bool |
||
235 | { |
||
236 | if ($this instanceof TalkBotConfig) { |
||
237 | try { |
||
238 | return $this->botTalk(); |
||
239 | } catch (Throwable) { |
||
240 | // do nothing |
||
241 | } |
||
242 | } |
||
243 | |||
244 | return false; |
||
245 | } |
||
246 | 4 | ||
247 | /** |
||
248 | 4 | * Is there a new message on the discussion page of the bot (or owner) ? |
|
249 | 4 | * @throws ConfigException |
|
250 | */ |
||
251 | 4 | public function checkWatchPages() |
|
252 | 2 | { |
|
253 | foreach ($this->getWatchPages() as $title => $lastTime) { |
||
254 | $pageTime = $this->getTimestamp($title); |
||
255 | 2 | ||
256 | // the page has been edited since last check ? |
||
257 | if (!$pageTime || $pageTime !== $lastTime) { |
||
258 | echo sprintf("WATCHPAGE '%s' has been edited since %s.\n", $title, $lastTime); |
||
259 | |||
260 | // Ask? Mettre à jour $watchPages ? |
||
261 | echo "Replace with $title => '$pageTime'"; |
||
262 | |||
263 | $this->checkExitOnWatchPage(); |
||
264 | } |
||
265 | 6 | } |
|
266 | } |
||
267 | 6 | ||
268 | 5 | /** |
|
269 | 6 | * @throws ConfigException |
|
270 | */ |
||
271 | 4 | protected function getWatchPages(): array |
|
272 | { |
||
273 | if (!file_exists(static::WATCHPAGE_FILENAME)) { |
||
274 | 2 | throw new ConfigException('No watchpage file found.'); |
|
275 | } |
||
276 | |||
277 | try { |
||
278 | $json = file_get_contents(static::WATCHPAGE_FILENAME); |
||
279 | $array = json_decode($json, true, 512, JSON_THROW_ON_ERROR); |
||
280 | } catch (Throwable) { |
||
281 | throw new ConfigException('Watchpage file malformed.'); |
||
282 | } |
||
283 | |||
284 | return $array; |
||
285 | } |
||
286 | |||
287 | protected function getTimestamp(string $title): ?string |
||
288 | { |
||
289 | $page = new WikiPageAction($this->mediawikiFactory, $title); |
||
290 | |||
291 | return $page->page->getRevisions()->getLatest()->getTimestamp(); |
||
292 | } |
||
293 | |||
294 | protected function checkExitOnWatchPage(): void |
||
295 | { |
||
296 | if (static::EXIT_ON_CHECK_WATCHPAGE) { |
||
297 | echo "EXIT_ON_CHECK_WATCHPAGE\n"; |
||
298 | |||
299 | throw new DomainException('exit from check watchpages'); |
||
300 | } |
||
301 | } |
||
302 | |||
303 | /** |
||
304 | * How many minutes since last edit ? Do not to disturb human editors ! |
||
305 | * @return int minutes |
||
306 | */ |
||
307 | public function minutesSinceLastEdit(string $title): int |
||
308 | { |
||
309 | $time = $this->getTimestamp($title); // 2011-09-02T16:31:13Z |
||
310 | |||
311 | return (int)round((time() - strtotime($time)) / 60); |
||
312 | } |
||
313 | } |
||
314 |
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.