Dispositif /
Wikibot
| 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.