* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0 */ namespace App\Bots; use Illuminate\Support\Facades\Log; class IRCAnnounceBot { /** * @var resource */ private $socket; private const RPL_WELCOME = 001; private string $recipient; private bool $isInChannel = false; public function __construct() { $socket = fsockopen(config('irc-bot.server'), config('irc-bot.port'), $_, $_, 5); if (!\is_resource($socket)) { return; } $this->socket = $socket; $this->nick(config('irc-bot.username')); $this->user(config('irc-bot.username'), config('irc-bot.hostname'), config('irc-bot.server'), config('irc-bot.username')); $this->connect(); if (config('irc-bot.nickservpass')) { $this->authenticate(config('irc-bot.username'), config('irc-bot.password')); } } public function __destruct() { sleep(2); if ($this->isInChannel) { $this->part($this->recipient); } $this->quit(); if (\is_resource($this->socket)) { fclose($this->socket); } } private function connect(): void { while (\is_resource($this->socket) && $message = fgets($this->socket)) { flush(); if ($message[0] === ':') { [, $command, $parameters] = preg_split('/ +/', $message, 3) + [null, null, null]; } else { [$command, $parameters] = preg_split('/ +/', $message, 3) + [null, null]; } switch ($command) { case 'PING': if ($parameters === null) { break; } [$server1] = preg_split('/ +/', (string) $parameters) + [null]; if ($server1 === null) { break; } $this->pong($server1); break; case self::RPL_WELCOME: // We have successfully connected break 2; } } } private function authenticate(string $username, string $password): void { $this->privmsg('NickServ', "RECOVER {$username} {$password}"); $this->nick($username); $this->privmsg('NickServ', "IDENTIFY {$password}"); } private function send(string $data): void { if (\is_resource($this->socket)) { fwrite($this->socket, $data."\r\n"); } else { Log::error('Tried to send data while not connected.'); } } public function to(string $recipient): self { $this->recipient = $recipient; $channelKey = config('irc-bot.channel_key', ''); if (config('irc-bot.joinchannel')) { if ($this->isValidChannelName($recipient)) { $this->join($recipient, $channelKey); $this->isInChannel = true; } else { Log::error('Tried to channel with invalid name.', [ 'name' => $this->recipient, ]); } } return $this; } public function say(string $message): self { if (! isset($this->recipient)) { Log::error('Tried to say a message without specifying a recipient.'); return $this; } $this->privmsg($this->recipient, $message); return $this; } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-2.3.1 */ private function isValidChannelName(string $channel): bool { // Channel must return // Length of the channel including the `#` or `&` must be at least 2 \strlen($channel) >= 2 // Channel name must begin with either `#` or `&` && \in_array($channel[0], ['#', '&'], true) // Channel names can contain any 8bit code except for SPACE, BELL, NUL, CR, LF and comma && !str_contains($channel, ' ') && !str_contains($channel, "\7") && !str_contains($channel, "\0") && !str_contains($channel, "\r") && !str_contains($channel, "\n") && !str_contains($channel, ','); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.1.2 */ private function nick(string $nickname): void { $this->send("NICK {$nickname}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.1.6 */ private function quit(string $message = ''): void { $this->send("QUIT {$message}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.1.3 */ private function user(string $username, string $hostname, string $servername, string $realname): void { $this->send("USER {$username} {$hostname} {$servername} {$realname}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.2.1 */ private function join(string $channel, string $channelKey = ''): void { if (!$this->isValidChannelName($channel)) { Log::error('Tried to join a channel with invalid name.', ['name' => $channel]); return; } $this->send("JOIN {$channel} {$channelKey}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.2.2 */ private function part(string $channel): void { if (!$this->isValidChannelName($channel)) { Log::error('Tried to part a channel with invalid name.', ['name' => $channel]); return; } $this->send("PART {$channel}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.4.1 */ private function privmsg(string $receiver, string $textToBeSent): void { $this->send("PRIVMSG {$receiver} :{$textToBeSent}"); } /** * @see https://www.rfc-editor.org/rfc/rfc1459#section-4.6.3 */ private function pong(string $daemon): void { $this->send("PONG {$daemon}"); } }