From 898d01d4ac5ccaa23621abda0761a893ff8c1074 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Wed, 1 Mar 2023 17:15:25 +0100 Subject: [PATCH] fix some twitch bot stuff --- .env.example | 1 - app/Console/Commands/TwitchAuth.php | 63 +++++++ app/Models/Channel.php | 6 + app/Models/TwitchToken.php | 47 +++++ app/TwitchBot/IRCMessage.php | 170 ++++++++++++++++++ app/TwitchBot/TwitchBot.php | 114 +++++++++--- config/twitch.php | 1 - ...3_01_084125_create_twitch_tokens_table.php | 36 ++++ .../2023_03_01_143514_channel_twitch_chat.php | 32 ++++ 9 files changed, 439 insertions(+), 31 deletions(-) create mode 100644 app/Console/Commands/TwitchAuth.php create mode 100644 app/Models/TwitchToken.php create mode 100644 app/TwitchBot/IRCMessage.php create mode 100644 database/migrations/2023_03_01_084125_create_twitch_tokens_table.php create mode 100644 database/migrations/2023_03_01_143514_channel_twitch_chat.php diff --git a/.env.example b/.env.example index ff8ed29..ffdc53b 100644 --- a/.env.example +++ b/.env.example @@ -72,5 +72,4 @@ AOS_URL=https://aos.localhorst.tv TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= -TWITCH_CODE= TWITCH_REDIRECT_URI= diff --git a/app/Console/Commands/TwitchAuth.php b/app/Console/Commands/TwitchAuth.php new file mode 100644 index 0000000..e46c118 --- /dev/null +++ b/app/Console/Commands/TwitchAuth.php @@ -0,0 +1,63 @@ +argument('nick')); + if (!$token) { + $token = new TwitchToken(); + $token->nick = $this->argument('nick'); + $token->scope = ['chat:read', 'chat:edit']; + } + $url = $token->getAuthUrl(); + $this->line('Please visit '.$url); + $code = $this->ask('and enter the authorization code here'); + $rsp = $this->fetchToken($code); + $token->type = $rsp['token_type']; + $token->access = $rsp['access_token']; + $token->refresh = $rsp['refresh_token']; + $token->save(); + return Command::SUCCESS; + } + + protected function fetchToken($code) { + $rsp = Http::post('https://id.twitch.tv/oauth2/token', [ + 'client_id' => config('twitch.client_id'), + 'client_secret' => config('twitch.client_secret'), + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => config('twitch.redirect_uri'), + ]); + if (!$rsp->successful()) { + throw new \Exception($rsp['message']); + } + return $rsp->json(); + } + +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php index d68ba58..8b5ff90 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -9,6 +9,12 @@ class Channel extends Model { use HasFactory; + public function episodes() { + return $this->belongsToMany(Episode::class) + ->using(Restream::class) + ->withPivot('accept_comms', 'accept_tracker'); + } + public function organization() { return $this->belongsTo(Organization::class); } diff --git a/app/Models/TwitchToken.php b/app/Models/TwitchToken.php new file mode 100644 index 0000000..9b15977 --- /dev/null +++ b/app/Models/TwitchToken.php @@ -0,0 +1,47 @@ +scope)); + } + + public function refresh() { + $rsp = Http::post('https://id.twitch.tv/oauth2/token', [ + 'client_id' => config('twitch.client_id'), + 'client_secret' => config('twitch.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->refresh, + ]); + if (!$rsp->successful()) { + throw new \Exception($rsp['message']); + } + $this->type = $rsp['token_type']; + $this->scope = $rsp['scope']; + $this->access = $rsp['access_token']; + $this->refresh = $rsp['refresh_token']; + $this->save(); + } + + protected $casts = [ + 'scope' => 'array', + ]; + + protected $hidden = [ + 'access', + 'refresh', + ]; + +} diff --git a/app/TwitchBot/IRCMessage.php b/app/TwitchBot/IRCMessage.php new file mode 100644 index 0000000..c0acfbb --- /dev/null +++ b/app/TwitchBot/IRCMessage.php @@ -0,0 +1,170 @@ +tags[$parts[0]] = $parts[1] == '' ? null : $parts[1]; + } + } + + if ($processed[0] == ':') { + $has_user = false; + $has_host = false; + $prefix = explode(' ', $processed, 2); + $processed = $prefix[1]; + $prefix = ltrim($prefix[0], ':'); + if (strpos($prefix, '!') !== false) { + $has_user = true; + } + if (strpos($prefix, '@') !== false) { + $has_host = true; + } + if ($has_user && $has_host) { + $prefix_part = explode('!', $prefix, 2); + $msg->nick = $prefix_part[0]; + $prefix_part = explode('@', $prefix_part[1], 2); + $msg->user = $prefix_part[0]; + $msg->host = $prefix_part[1]; + } else if ($has_user) { + $prefix_part = explode('!', $prefix, 2); + $msg->nick = $prefix_part[0]; + $msg->user = $prefix_part[1]; + } else if ($has_host) { + $prefix_part = explode('@', $prefix, 2); + $msg->nick = $prefix_part[0]; + $msg->host = $prefix_part[1]; + } else { + $msg->servername = $prefix; + } + } + + $command = explode(' ', $processed, 2); + $msg->command = $command[0]; + $processed = $command[1] ?? ''; + + while (strlen($processed)) { + if ($processed[0] == ':') { + $msg->params[] = ltrim($processed, ':'); + break; + } + $e = explode(' ', $processed, 2); + $msg->params[] = $e[0]; + if (isset($e[1])) { + $processed = $e[1]; + } else { + break; + } + } + + return $msg; + } + + public function encode() { + $str = ''; + if (!empty($this->tags)) { + $str .= '@'; + $first = true; + foreach ($this->tags as $name => $value) { + if ($first) { + $first = false; + } else { + $str .= ';'; + } + $str .= $name.'='; + if (!empty($value)) { + $str .= $value; + } + } + $str .= ' '; + } + + if (!empty($this->servername)) { + $str .= ':'.$this->servername.' '; + } else if (!empty($this->nick)) { + $str .= ':'.$this->nick; + if (!empty($this->user)) { + $str .= '!'.$this->user; + } + if (!empty($this->host)) { + $str .= '@'.$this->host; + } + $str .= ' '; + } + + $str .= $this->command; + + if (!empty($this->params)) { + $n = count($this->params) - 1; + for ($i = 0; $i < $n; ++$i) { + $str .= ' '.$this->params[$i]; + } + $str .= ' :'.$this->params[$n]; + } + + return $str; + } + + public static function join($channels) { + $msg = new IRCMessage(); + $msg->command = 'JOIN'; + $msg->params[] = implode(',', $channels); + return $msg; + } + + public function getPrivMsgTarget() { + if (!empty($this->params)) { + return $this->params[0]; + } + return ''; + } + + public function getText() { + if (!empty($this->params)) { + return $this->params[count($this->params) - 1]; + } + return ''; + } + + public function isNotice() { + return $this->command == 'NOTICE'; + } + + public function isPing() { + return $this->command == 'PING'; + } + + public function isPrivMsg() { + return $this->command == 'PRIVMSG'; + } + + public function makePong() { + $msg = new IRCMessage(); + $msg->command = 'PONG'; + $msg->params = array_values($this->params); + return $msg; + } + +} + +?> diff --git a/app/TwitchBot/TwitchBot.php b/app/TwitchBot/TwitchBot.php index fd28378..3ca2db9 100644 --- a/app/TwitchBot/TwitchBot.php +++ b/app/TwitchBot/TwitchBot.php @@ -1,7 +1,9 @@ logger = new Logger('TwitchBot'); - $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::INFO)); + $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); - $this->fetchToken(); + $this->token = TwitchToken::firstWhere('nick', 'localhorsttv'); + if (!$this->token) { + throw new \Exception('unable to find access token'); + } $this->connector = new Connector(); $this->connect(); @@ -30,30 +35,18 @@ class TwitchBot { } public function run() { + $this->shutting_down = false; $this->getLoop()->run(); } public function stop() { + $this->logger->info('shutting down'); + $this->shutting_down = true; $this->disconnect(); $this->getLoop()->stop(); } - public function fetchToken() { - $this->logger->info('acquiring token'); - $rsp = Http::post('https://id.twitch.tv/oauth2/token', [ - 'client_id' => config('twitch.client_id'), - 'client_secret' => config('twitch.client_secret'), - 'code' => config('twitch.code'), - 'grant_type' => 'authorization_code', - 'redirect_uri' => config('twitch.redirect_uri'), - ]); - var_dump($rsp); - var_dump($rsp->body()); - $this->token = $rsp->json(); - } - - public function connect() { ($this->connector)('wss://irc-ws.chat.twitch.tv:443')->done( [$this, 'handleWsConnect'], @@ -66,14 +59,13 @@ class TwitchBot { } public function handleWsConnect(WebSocket $ws) { - $this->logger->info('websocket connection estblished'); + $this->logger->info('websocket connection established'); $this->ws = $ws; - $ws->on('message', [$this, 'handleWsMessage']); - $ws->on('close', [$this, 'handleWsClose']); - $ws->on('error', [$this, 'handleWsError']); + $ws->on('message', [$this, 'handleWsMessage']); + $ws->on('close', [$this, 'handleWsClose']); + $ws->on('error', [$this, 'handleWsError']); $ws->send('CAP REQ :twitch.tv/tags twitch.tv/commands'); - $ws->send('PASS oauth:'.$this->token->access_token); - $ws->send('NICK localhorsttv'); + $this->login(); } public function handleWsConnectError(WebSocket $ws) { @@ -81,26 +73,90 @@ class TwitchBot { } public function handleWsMessage(Message $message, WebSocket $ws) { - $this->logger->info('websocket message received'); - var_dump($message->getPayload()); + $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n")); + foreach ($irc_messages as $irc_message) { + $this->logger->debug('received IRC message '.$irc_message); + $this->handleIRCMessage(IRCMessage::fromString($irc_message)); + } } - public function handleWsClose(int $op, string $reason) { + public function handleWsClose(int $op, string $reason) { $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']'); + if (!$this->shutting_down) { + $this->logger->info('reconnecting in 10 seconds'); + Loop::addTimer(10, [$this, 'connect']); + } } - public function handleWsError(\Exception $e, WebSocket $ws) { + public function handleWsError(\Exception $e, WebSocket $ws) { $this->logger->error('websocket error '.$e->getMessage()); } + public function handleIRCMessage(IRCMessage $msg) { + if ($msg->isPrivMsg()) { + $this->handlePrivMsg($msg); + return; + } + if ($msg->isPing()) { + $this->sendIRCMessage($msg->makePong()); + return; + } + if ($msg->isNotice() && $msg->getText() == 'Login authentication failed') { + $this->logger->notice('login failed, refreshing access token'); + $this->token->refresh(); + $this->login(); + return; + } + if ($msg->command == '001') { + // successful login + $this->joinChannels(); + return; + } + } + + public function handlePrivMsg(IRCMessage $msg) { + $target = $msg->getPrivMsgTarget(); + if ($target[0] != '#') return; + $text = $msg->getText(); + if ($text[0] != '!') return; + $channel = Channel::firstWhere('twitch_chat', '=', $target); + if (!$channel) return; + $this->logger->info('got command '.$text.' on channel '.$channel->title); + } + + public function login() { + $this->ws->send('PASS oauth:'.$this->token->access); + $this->ws->send('NICK localhorsttv'); + } + + public function joinChannels() { + $this->logger->info('joining channels'); + $channels = Channel::where('twitch_chat', '!=', '')->get(); + $names = []; + foreach ($channels as $channel) { + $names[] = $channel->twitch_chat; + } + $chunks = array_chunk($names, 10); + foreach ($chunks as $chunk) { + $this->sendIRCMessage(IRCMessage::join($chunk)); + } + } + + public function sendIRCMessage(IRCMessage $msg) { + $irc_message = $msg->encode(); + $this->logger->debug('sending IRC message '.$irc_message); + $this->ws->send($irc_message); + } + + private $logger; - private $loop; private $token; private $connector; private $ws; + private $shutting_down = false; } diff --git a/config/twitch.php b/config/twitch.php index 6be7861..ceda42e 100644 --- a/config/twitch.php +++ b/config/twitch.php @@ -3,6 +3,5 @@ return [ 'client_id' => env('TWITCH_CLIENT_ID', ''), 'client_secret' => env('TWITCH_CLIENT_SECRET', ''), - 'code' => env('TWITCH_CODE', ''), 'redirect_uri' => env('TWITCH_REDIRECT_URI', ''), ]; diff --git a/database/migrations/2023_03_01_084125_create_twitch_tokens_table.php b/database/migrations/2023_03_01_084125_create_twitch_tokens_table.php new file mode 100644 index 0000000..4652ab8 --- /dev/null +++ b/database/migrations/2023_03_01_084125_create_twitch_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('nick'); + $table->string('type'); + $table->text('scope'); + $table->string('access'); + $table->string('refresh'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('twitch_tokens'); + } +}; diff --git a/database/migrations/2023_03_01_143514_channel_twitch_chat.php b/database/migrations/2023_03_01_143514_channel_twitch_chat.php new file mode 100644 index 0000000..b9d3901 --- /dev/null +++ b/database/migrations/2023_03_01_143514_channel_twitch_chat.php @@ -0,0 +1,32 @@ +string('twitch_chat')->default(''); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('events', function(Blueprint $table) { + $table->dropColumn('twitch_chat'); + }); + } +}; -- 2.39.2