3 namespace App\TwitchBot;
5 use App\Models\Channel;
6 use App\Models\TwitchBotCommand;
7 use App\Models\TwitchToken;
8 use Monolog\Handler\StreamHandler;
10 use Ratchet\Client\Connector;
11 use Ratchet\Client\WebSocket;
12 use Ratchet\RFC6455\Messaging\Message;
13 use React\EventLoop\Loop;
17 public function __construct() {
18 $this->logger = new Logger('TwitchBot');
19 $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::INFO));
21 $this->token = TwitchToken::firstWhere('nick', 'localhorsttv');
23 throw new \Exception('unable to find access token');
26 $this->connector = new Connector();
28 $this->listenCommands();
32 public function getLogger() {
36 public function getLoop() {
40 public function run() {
41 $this->shutting_down = false;
42 $this->getLoop()->run();
45 public function stop() {
46 $this->logger->info('shutting down');
47 $this->shutting_down = true;
49 $this->getLoop()->stop();
53 public function connect() {
54 ($this->connector)('wss://irc-ws.chat.twitch.tv:443')->done(
55 [$this, 'handleWsConnect'],
56 [$this, 'handleWsConnectError'],
60 public function disconnect() {
64 public function handleWsConnect(WebSocket $ws) {
65 $this->logger->info('websocket connection established');
67 $ws->on('message', [$this, 'handleWsMessage']);
68 $ws->on('close', [$this, 'handleWsClose']);
69 $ws->on('error', [$this, 'handleWsError']);
70 $ws->send('CAP REQ :twitch.tv/tags twitch.tv/commands');
74 public function handleWsConnectError(WebSocket $ws) {
75 $this->logger->error('failed to establish websocket connection');
78 public function handleWsMessage(Message $message, WebSocket $ws) {
79 $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n"));
80 foreach ($irc_messages as $irc_message) {
81 $this->logger->info('received IRC message '.$irc_message);
82 $this->handleIRCMessage(IRCMessage::fromString($irc_message));
86 public function handleWsClose(int $op, string $reason) {
88 $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']');
89 if (!$this->shutting_down) {
90 $this->logger->info('reconnecting in 5 seconds');
91 Loop::addTimer(5, [$this, 'connect']);
95 public function handleWsError(\Exception $e, WebSocket $ws) {
96 $this->logger->error('websocket error '.$e->getMessage());
100 public function handleIRCMessage(IRCMessage $msg) {
101 $this->last_contact = time();
102 if ($msg->isPing()) {
103 $this->sendIRCMessage($msg->makePong());
106 if ($msg->isPong()) {
110 if ($msg->isPrivMsg()) {
111 $this->handlePrivMsg($msg);
114 if ($msg->isNotice() && $msg->getText() == 'Login authentication failed') {
115 $this->logger->notice('login failed, refreshing access token');
116 $this->token->refresh();
120 if ($msg->command == '001') {
122 $this->joinChannels();
128 public function handlePrivMsg(IRCMessage $msg) {
129 $target = $msg->getPrivMsgTarget();
130 if ($target[0] != '#') return; // direct message
131 $text = $msg->getText();
132 if ($text[0] != '!') return;
133 $channel = Channel::firstWhere('twitch_chat', '=', $target);
134 if (!$channel) return;
135 $this->handleChatCommand($channel, $msg);
138 public function handleChatCommand(Channel $channel, IRCMessage $msg) {
139 $cmd = explode(' ', ltrim($msg->getText(), '!'), 2);
140 if (!isset($channel->chat_commands[$cmd[0]])) return;
141 $config = $channel->chat_commands[$cmd[0]];
142 $this->logger->info('got command '.$cmd[0].' on channel '.$channel->title);
144 $command = ChatCommand::create($this, $channel, $config);
145 $command->execute($cmd[1] ?? '');
146 } catch (\Exception $e) {
147 $this->logger->warning('error executing command '.$cmd[0].' on channel '.$channel->title.': '.$e->getMessage());
151 public function login() {
152 $this->ws->send('PASS oauth:'.$this->token->access);
153 $this->ws->send('NICK localhorsttv');
156 public function joinChannels() {
157 $this->logger->info('joining channels');
158 $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get();
160 foreach ($channels as $channel) {
161 $names[] = $channel->twitch_chat;
163 $chunks = array_chunk($names, 10);
164 foreach ($chunks as $chunk) {
165 $this->sendIRCMessage(IRCMessage::join($chunk));
169 private function listenCommands() {
170 $this->getLoop()->addPeriodicTimer(1, function () {
171 if (!$this->ready) return;
172 $command = TwitchBotCommand::where('status', '=', 'pending')->oldest()->first();
175 $command->execute($this);
176 } catch (\Exception $e) {
182 private function startPinger() {
183 $this->getLoop()->addPeriodicTimer(15, function () {
184 if (!$this->ready) return;
185 if (time() - $this->last_contact < 60) return;
187 $this->sendIRCMessage(IRCMessage::ping());
188 } catch (\Exception $e) {
193 public function sendIRCMessage(IRCMessage $msg) {
194 $irc_message = $msg->encode();
195 $this->logger->info('sending IRC message '.$irc_message);
196 $this->ws->send($irc_message);
197 $this->last_contact = time();
207 private $ready = false;
208 private $shutting_down = false;
210 private $last_contact;