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($nick) {
19 $this->logger = new Logger('TwitchBot');
20 $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::INFO));
22 $this->token = TwitchToken::firstWhere('nick', $nick);
24 throw new \Exception('unable to find access token');
26 if ($this->token->hasExpired()) {
27 $this->token->refresh();
30 $this->connector = new Connector();
35 public function getLogger() {
39 public function getLoop() {
43 public function isReady() {
47 public function run() {
48 $this->shutting_down = false;
49 $this->getLoop()->run();
52 public function stop() {
53 $this->logger->info('shutting down');
54 $this->shutting_down = true;
56 $this->getLoop()->stop();
60 public function connect() {
61 ($this->connector)('wss://irc-ws.chat.twitch.tv:443')->done(
62 [$this, 'handleWsConnect'],
63 [$this, 'handleWsConnectError'],
67 public function disconnect() {
71 public function handleWsConnect(WebSocket $ws) {
72 $this->logger->info('websocket connection established');
74 $ws->on('message', [$this, 'handleWsMessage']);
75 $ws->on('close', [$this, 'handleWsClose']);
76 $ws->on('error', [$this, 'handleWsError']);
77 $ws->send('CAP REQ :twitch.tv/tags twitch.tv/commands');
81 public function handleWsConnectError(WebSocket $ws) {
82 $this->logger->error('failed to establish websocket connection');
85 public function handleWsMessage(Message $message, WebSocket $ws) {
86 $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n"));
87 foreach ($irc_messages as $irc_message) {
88 $this->logger->info('received IRC message '.$irc_message);
89 $this->handleIRCMessage(IRCMessage::fromString($irc_message));
93 public function handleWsClose(int $op, string $reason) {
95 $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']');
96 if (!$this->shutting_down) {
97 $this->logger->info('reconnecting in 5 seconds');
98 Loop::addTimer(5, [$this, 'connect']);
102 public function handleWsError(\Exception $e, WebSocket $ws) {
103 $this->logger->error('websocket error '.$e->getMessage());
107 public function handleIRCMessage(IRCMessage $msg) {
108 $this->last_contact = time();
109 if ($msg->isPing()) {
110 $this->sendIRCMessage($msg->makePong());
113 if ($msg->isPong()) {
116 $this->logMessage($msg);
117 if ($msg->isPrivMsg()) {
118 $this->handlePrivMsg($msg);
121 if ($msg->isWhisper()) {
122 $this->handleWhisper($msg);
125 if ($msg->isRoomstate()) {
126 $this->handleRoomstate($msg);
129 if ($msg->isNotice() && $msg->getText() == 'Login authentication failed') {
130 $this->logger->notice('login failed, refreshing access token');
131 $this->token->refresh();
135 if ($msg->command == '001') {
137 $this->joinChannels();
141 if ($msg->command == 'GLOBALUSERSTATE') {
142 // receive own user metadata
143 $this->handleUserState($msg);
148 public function getMessageChannel(IRCMessage $msg) {
149 $target = $msg->getPrivMsgTarget();
150 if (substr($target, 0, 1) !== '#') {
151 $target = '#'.$target;
153 return Channel::firstWhere('twitch_chat', '=', $target);
156 public function logMessage(IRCMessage $msg) {
159 public function handlePrivMsg(IRCMessage $msg) {
162 public function handleRoomstate(IRCMessage $msg) {
165 public function handleUserState(IRCMessage $msg) {
166 if (isset($msg->tags['user-id'])) {
167 $this->user_id = $msg->tags['user-id'];
171 public function handleWhisper(IRCMessage $msg) {
174 public function login() {
175 $this->ws->send('PASS oauth:'.$this->token->access);
176 $this->ws->send('NICK '.$this->nick);
179 public function joinChannels() {
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($this->nick));
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();
200 public function sendWhisper($to, $msg) {
201 $this->logger->info('sending whisper to '.$to.': '.$msg);
203 $response = $this->token->request()->post('/whispers?from_user_id='.$this->user_id.'&to_user_id='.$to, [
206 if (!$response->successful()) {
207 $this->logger->error('sending whisper to '.$to.': '.$response->status());
209 } catch (\Exception $e) {
210 $this->logger->error('sending whisper to '.$to.': '.$e->getMessage());
215 protected function listenCommands() {
216 $this->getLoop()->addPeriodicTimer(1, function () {
217 if (!$this->isReady()) return;
218 $command = TwitchBotCommand::where('bot_nick', '=', $this->nick)->where('status', '=', 'pending')->oldest()->first();
221 $command->execute($this);
222 } catch (\Exception $e) {
233 private $user_id = '';
237 private $ready = false;
238 private $shutting_down = false;
240 private $last_contact;