]> git.localhorst.tv Git - alttp.git/blob - app/TwitchBot/TwitchBot.php
basic twitch join/part commands
[alttp.git] / app / TwitchBot / TwitchBot.php
1 <?php
2
3 namespace App\TwitchBot;
4
5 use App\Models\Channel;
6 use App\Models\TwitchBotCommand;
7 use App\Models\TwitchToken;
8 use Monolog\Handler\StreamHandler;
9 use Monolog\Logger;
10 use Ratchet\Client\Connector;
11 use Ratchet\Client\WebSocket;
12 use Ratchet\RFC6455\Messaging\Message;
13 use React\EventLoop\Loop;
14
15 class TwitchBot {
16
17         public function __construct() {
18                 $this->logger = new Logger('TwitchBot');
19                 $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::INFO));
20
21                 $this->token = TwitchToken::firstWhere('nick', 'localhorsttv');
22                 if (!$this->token) {
23                         throw new \Exception('unable to find access token');
24                 }
25
26                 $this->connector = new Connector();
27                 $this->connect();
28                 $this->listenCommands();
29         }
30
31         public function getLogger() {
32                 return $this->logger;
33         }
34
35         public function getLoop() {
36                 return Loop::get();
37         }
38
39         public function run() {
40                 $this->shutting_down = false;
41                 $this->getLoop()->run();
42         }
43
44         public function stop() {
45                 $this->logger->info('shutting down');
46                 $this->shutting_down = true;
47                 $this->disconnect();
48                 $this->getLoop()->stop();
49         }
50
51
52         public function connect() {
53                 ($this->connector)('wss://irc-ws.chat.twitch.tv:443')->done(
54                         [$this, 'handleWsConnect'],
55                         [$this, 'handleWsConnectError'],
56                 );
57         }
58
59         public function disconnect() {
60                 $this->ws->close();
61         }
62
63         public function handleWsConnect(WebSocket $ws) {
64                 $this->logger->info('websocket connection established');
65                 $this->ws = $ws;
66                 $ws->on('message', [$this, 'handleWsMessage']);
67                 $ws->on('close', [$this, 'handleWsClose']);
68                 $ws->on('error', [$this, 'handleWsError']);
69                 $ws->send('CAP REQ :twitch.tv/tags twitch.tv/commands');
70                 $this->login();
71         }
72
73         public function handleWsConnectError(WebSocket $ws) {
74                 $this->logger->error('failed to establish websocket connection');
75         }
76
77         public function handleWsMessage(Message $message, WebSocket $ws) {
78                 $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n"));
79                 foreach ($irc_messages as $irc_message) {
80                         $this->logger->info('received IRC message '.$irc_message);
81                         $this->handleIRCMessage(IRCMessage::fromString($irc_message));
82                 }
83         }
84
85         public function handleWsClose(int $op, string $reason) {
86                 $this->ready = false;
87                 $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']');
88                 if (!$this->shutting_down) {
89                         $this->logger->info('reconnecting in 10 seconds');
90                         Loop::addTimer(10, [$this, 'connect']);
91                 }
92         }
93
94         public function handleWsError(\Exception $e, WebSocket $ws) {
95                 $this->logger->error('websocket error '.$e->getMessage());
96         }
97
98
99         public function handleIRCMessage(IRCMessage $msg) {
100                 if ($msg->isPrivMsg()) {
101                         $this->handlePrivMsg($msg);
102                         return;
103                 }
104                 if ($msg->isPing()) {
105                         $this->sendIRCMessage($msg->makePong());
106                         return;
107                 }
108                 if ($msg->isNotice() && $msg->getText() == 'Login authentication failed') {
109                         $this->logger->notice('login failed, refreshing access token');
110                         $this->token->refresh();
111                         $this->login();
112                         return;
113                 }
114                 if ($msg->command == '001') {
115                         // successful login
116                         $this->joinChannels();
117                         $this->ready = true;
118                         return;
119                 }
120         }
121
122         public function handlePrivMsg(IRCMessage $msg) {
123                 $target = $msg->getPrivMsgTarget();
124                 if ($target[0] != '#') return;
125                 $text = $msg->getText();
126                 if ($text[0] != '!') return;
127                 $channel = Channel::firstWhere('twitch_chat', '=', $target);
128                 if (!$channel) return;
129                 $this->handleChatCommand($channel, $msg);
130         }
131
132         public function handleChatCommand(Channel $channel, IRCMessage $msg) {
133                 $cmd = explode(' ', ltrim($msg->getText(), '!'), 2);
134                 if (!isset($channel->chat_commands[$cmd[0]])) return;
135                 $config = $channel->chat_commands[$cmd[0]];
136                 $this->logger->info('got command '.$cmd[0].' on channel '.$channel->title);
137                 try {
138                         $command = ChatCommand::create($this, $channel, $config);
139                         $command->execute($cmd[1] ?? '');
140                 } catch (\Exception $e) {
141                         $this->logger->warning('error executing command '.$cmd[0].' on channel '.$channel->title.': '.$e->getMessage());
142                 }
143         }
144
145         public function login() {
146                 $this->ws->send('PASS oauth:'.$this->token->access);
147                 $this->ws->send('NICK localhorsttv');
148         }
149
150         public function joinChannels() {
151                 $this->logger->info('joining channels');
152                 $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get();
153                 $names = [];
154                 foreach ($channels as $channel) {
155                         $names[] = $channel->twitch_chat;
156                 }
157                 $chunks = array_chunk($names, 10);
158                 foreach ($chunks as $chunk) {
159                         $this->sendIRCMessage(IRCMessage::join($chunk));
160                 }
161         }
162
163         private function listenCommands() {
164                 $this->getLoop()->addPeriodicTimer(1, function () {
165                         if (!$this->ready) return;
166                         $command = TwitchBotCommand::where('status', '=', 'pending')->oldest()->first();
167                         if ($command) {
168                                 try {
169                                         $command->execute($this);
170                                 } catch (\Exception $e) {
171                                 }
172                         }
173                 });
174
175         }
176
177         public function sendIRCMessage(IRCMessage $msg) {
178                 $irc_message = $msg->encode();
179                 $this->logger->info('sending IRC message '.$irc_message);
180                 $this->ws->send($irc_message);
181         }
182
183
184         private $logger;
185
186         private $token;
187
188         private $connector;
189         private $ws;
190         private $ready = false;
191         private $shutting_down = false;
192
193 }
194
195 ?>