]> git.localhorst.tv Git - alttp.git/commitdiff
fix some twitch bot stuff
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 1 Mar 2023 16:15:25 +0000 (17:15 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 1 Mar 2023 16:15:25 +0000 (17:15 +0100)
.env.example
app/Console/Commands/TwitchAuth.php [new file with mode: 0644]
app/Models/Channel.php
app/Models/TwitchToken.php [new file with mode: 0644]
app/TwitchBot/IRCMessage.php [new file with mode: 0644]
app/TwitchBot/TwitchBot.php
config/twitch.php
database/migrations/2023_03_01_084125_create_twitch_tokens_table.php [new file with mode: 0644]
database/migrations/2023_03_01_143514_channel_twitch_chat.php [new file with mode: 0644]

index ff8ed295432d272a595b6f7123b7318936f7a92e..ffdc53b6065c212a4ea072383309f16b3f7f9a1e 100644 (file)
@@ -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 (file)
index 0000000..e46c118
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\TwitchToken;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+
+class TwitchAuth extends Command {
+
+       /**
+        * The name and signature of the console command.
+        *
+        * @var string
+        */
+       protected $signature = 'twitch:auth {nick}';
+
+       /**
+        * The console command description.
+        *
+        * @var string
+        */
+       protected $description = 'Acquire a twitch oauth token';
+
+       /**
+        * Execute the console command.
+        *
+        * @return int
+        */
+       public function handle()
+       {
+               $token = TwitchToken::firstWhere('nick', $this->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();
+       }
+
+}
index d68ba5884888838271dbceac3e9ab8b3a3314c4f..8b5ff9007e2359c93bc105cdcd9b7e13fb1f73ce 100644 (file)
@@ -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 (file)
index 0000000..9b15977
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Http;
+
+class TwitchToken extends Model
+{
+       use HasFactory;
+
+       public function getAuthUrl() {
+               return 'https://id.twitch.tv/oauth2/authorize'
+                       .'?response_type=code'
+                       .'&client_id='.rawurlencode(config('twitch.client_id'))
+                       .'&redirect_uri='.rawurlencode(config('twitch.redirect_uri'))
+                       .'&scope='.implode('+', array_map('rawurlencode', $this->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 (file)
index 0000000..c0acfbb
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+
+namespace App\TwitchBot;
+
+class IRCMessage {
+
+       public $command = null;
+       public $params = [];
+       public $nick = null;
+       public $user = null;
+       public $host = null;
+       public $servername = null;
+       public $tags = [];
+
+       public static function fromString($message) {
+               $msg = new static();
+
+               $raw = rtrim($message);
+               $processed = $raw;
+
+               if ($processed[0] == '@') {
+                       $tags = explode(' ', $processed, 2);
+                       $processed = $tags[1];
+                       $tags = explode(';', ltrim(trim($tags[0]), '@'));
+                       foreach ($tags as $tag) {
+                               $parts = explode('=', $tag, 2);
+                               $msg->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;
+       }
+
+}
+
+?>
index fd283789b9878187907ef60baed3cc30bc3d350b..3ca2db9283e6651e521fa2fe4aeaa596d94f9e2e 100644 (file)
@@ -1,7 +1,9 @@
 <?php
 
 namespace App\TwitchBot;
-use Illuminate\Support\Facades\Http;
+
+use App\Models\Channel;
+use App\Models\TwitchToken;
 use Monolog\Handler\StreamHandler;
 use Monolog\Logger;
 use Ratchet\Client\Connector;
@@ -13,9 +15,12 @@ class TwitchBot {
 
        public function __construct() {
                $this->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;
 
 }
 
index 6be78611aff44de832d493686b3330c46d4edef8..ceda42eddfa5266ddf5b026f6e7b3b1ffda590dd 100644 (file)
@@ -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 (file)
index 0000000..4652ab8
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::create('twitch_tokens', function (Blueprint $table) {
+                       $table->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 (file)
index 0000000..b9d3901
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::table('channels', function(Blueprint $table) {
+                       $table->string('twitch_chat')->default('');
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::table('events', function(Blueprint $table) {
+                       $table->dropColumn('twitch_chat');
+               });
+       }
+};