TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
-TWITCH_CODE=
TWITCH_REDIRECT_URI=
--- /dev/null
+<?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();
+ }
+
+}
{
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);
}
--- /dev/null
+<?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',
+ ];
+
+}
--- /dev/null
+<?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;
+ }
+
+}
+
+?>
<?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;
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();
}
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'],
}
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) {
}
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;
}
return [
'client_id' => env('TWITCH_CLIENT_ID', ''),
'client_secret' => env('TWITCH_CLIENT_SECRET', ''),
- 'code' => env('TWITCH_CODE', ''),
'redirect_uri' => env('TWITCH_REDIRECT_URI', ''),
];
--- /dev/null
+<?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');
+ }
+};
--- /dev/null
+<?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');
+ });
+ }
+};