From d1b21eaab27ce1102e275c11f970dea2c684cd7a Mon Sep 17 00:00:00 2001 From: kyledoesdev Date: Mon, 26 Jan 2026 22:59:44 -0500 Subject: [PATCH 1/2] Adds Twitch live status checker Implements a scheduled task to check if spacelampsix is live on Twitch. If the stream is live and hasn't been notified before, a notification is sent to a Discord channel. Also introduces a scheduled task to refresh the Twitch access token monthly. Uses an Enum for connection types instead of a database table. --- .../Commands/CheckTwitchLiveStatus.php | 89 +++++++++++++++++++ .../Commands/RefreshTwitchAccessToken.php | 35 ++++++++ app/Enums/ConnectionType.php | 25 ++++++ app/Http/Controllers/ConnectionController.php | 8 +- app/Livewire/Actions/Api/SearchSpotify.php | 6 +- .../Actions/Api/Twitch/RefreshToken.php | 38 ++++++++ .../Api/{ => Twitch}/SearchCategories.php | 36 ++------ app/Livewire/Forms/LoginForm.php | 5 +- app/Models/Connection.php | 5 -- app/Models/ConnectionType.php | 14 --- composer.json | 1 + composer.lock | 72 ++++++++++++++- config/logging.php | 16 ++++ config/services.php | 5 ++ ..._03_25_221715_create_connections_table.php | 6 +- ..._27_033427_drop_connection_types_table.php | 16 ++++ ...4050_create_twitch_notifications_table.php | 18 ++++ resources/views/livewire/connection.blade.php | 4 +- routes/console.php | 7 ++ 19 files changed, 341 insertions(+), 65 deletions(-) create mode 100644 app/Console/Commands/CheckTwitchLiveStatus.php create mode 100644 app/Console/Commands/RefreshTwitchAccessToken.php create mode 100644 app/Enums/ConnectionType.php create mode 100644 app/Livewire/Actions/Api/Twitch/RefreshToken.php rename app/Livewire/Actions/Api/{ => Twitch}/SearchCategories.php (58%) delete mode 100644 app/Models/ConnectionType.php create mode 100644 database/migrations/2026_01_27_033427_drop_connection_types_table.php create mode 100644 database/migrations/2026_01_27_034050_create_twitch_notifications_table.php diff --git a/app/Console/Commands/CheckTwitchLiveStatus.php b/app/Console/Commands/CheckTwitchLiveStatus.php new file mode 100644 index 0000000..df408ea --- /dev/null +++ b/app/Console/Commands/CheckTwitchLiveStatus.php @@ -0,0 +1,89 @@ +connections->firstWhere('type_id', ConnectionType::TWITCH->value)->token ?? null; + + if (! $token) { + return self::FAILURE; + } + + $stream = $this->getStreamData($token); + + if (! $stream) { + $this->info('spacelampsix is not currently live.'); + return self::SUCCESS; + } + + $startedAt = $stream['started_at']; + + if ($this->alreadyNotified($startedAt)) { + $this->info('Already notified for this stream session.'); + return self::SUCCESS; + } + + $this->notifyDiscord($stream); + $this->recordNotification($startedAt); + + $this->info('Discord notification sent!'); + return self::SUCCESS; + } + + private function getStreamData(string $token): ?array + { + $response = Http::withHeaders([ + 'Client-ID' => config('services.twitch.client_id'), + 'Authorization' => 'Bearer ' . $token, + ])->get('https://api.twitch.tv/helix/streams', [ + 'user_login' => 'spacelampsix', + ]); + + return $response->json('data.0'); + } + + private function alreadyNotified(string $startedAt): bool + { + return DB::table('twitch_notifications') + ->where('stream_started_at', $startedAt) + ->exists(); + } + + private function recordNotification(string $startedAt): void + { + DB::table('twitch_notifications')->insert([ + 'stream_started_at' => $startedAt, + 'notified_at' => now(), + ]); + } + + private function notifyDiscord(array $stream): void + { + $title = $stream['title'] ?? 'Untitled Stream'; + $game = $stream['game_name'] ?? 'Unknown Game'; + + Http::post(config('services.discord.live_now'), [ + 'content' => "@everyone 🔴 **spacelampsix is LIVE!**\n\n**{$title}**\nPlaying: {$game}\n\nhttps://twitch.tv/spacelampsix", + 'allowed_mentions' => [ + 'parse' => ['everyone'], + ], + ]); + + Log::channel('discord-internal-updates')->info("Discord notified of live stream successfully. Have a great stream!"); + } +} \ No newline at end of file diff --git a/app/Console/Commands/RefreshTwitchAccessToken.php b/app/Console/Commands/RefreshTwitchAccessToken.php new file mode 100644 index 0000000..8f2216d --- /dev/null +++ b/app/Console/Commands/RefreshTwitchAccessToken.php @@ -0,0 +1,35 @@ +handle(User::first()); + + Log::channel('discord-internal-updates')->info("Twitch Token updated automatically."); + } +} diff --git a/app/Enums/ConnectionType.php b/app/Enums/ConnectionType.php new file mode 100644 index 0000000..2794a32 --- /dev/null +++ b/app/Enums/ConnectionType.php @@ -0,0 +1,25 @@ + 'Spotify', + self::TWITCH => 'Twitch', + }; + } + + public function slug() + { + return match($this) { + self::SPOTIFY => 'spotify', + self::TWITCH => 'twitch', + }; + } +} diff --git a/app/Http/Controllers/ConnectionController.php b/app/Http/Controllers/ConnectionController.php index b109d03..f382f53 100644 --- a/app/Http/Controllers/ConnectionController.php +++ b/app/Http/Controllers/ConnectionController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Models\ConnectionType; +use App\Enums\ConnectionType; use Illuminate\Http\Request; use Laravel\Socialite\Facades\Socialite; @@ -35,9 +35,11 @@ public function processConnection(Request $request) private function getConnectionTypeId(string $type) { + dd(ConnectionType::TWITCH->slug()); + return match ($type) { - 'twitch' => ConnectionType::TWITCH, - 'spotify' => ConnectionType::SPOTIFY + 'twitch' => ConnectionType::TWITCH->slug(), + 'spotify' => ConnectionType::SPOTIFY->slug() }; } } diff --git a/app/Livewire/Actions/Api/SearchSpotify.php b/app/Livewire/Actions/Api/SearchSpotify.php index 33337a8..8eb8165 100644 --- a/app/Livewire/Actions/Api/SearchSpotify.php +++ b/app/Livewire/Actions/Api/SearchSpotify.php @@ -2,7 +2,7 @@ namespace App\Livewire\Actions\Api; -use App\Models\ConnectionType; +use App\Enums\ConnectionType; use App\Models\Media; use App\Models\MediaType; use App\Models\User; @@ -19,7 +19,7 @@ public function search(User $user, string $phrase, MediaType $mediaType) $type = $mediaType->isArtist() ? 'artist' : 'track'; $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$user->connections->firstWhere('type_id', ConnectionType::SPOTIFY)->token, + 'Authorization' => 'Bearer '.$user->connections->firstWhere('type_id', ConnectionType::SPOTIFY->value)->token, 'Content-Type' => 'application/json', 'Client-Id' => config('services.spotify.client_id'), ])->get('https://api.spotify.com/v1/search', [ @@ -81,7 +81,7 @@ public function search(User $user, string $phrase, MediaType $mediaType) public function refreshToken(User $user): bool { try { - $spotifyConnection = $user->connections->firstWhere('type_id', ConnectionType::SPOTIFY); + $spotifyConnection = $user->connections->firstWhere('type_id', ConnectionType::SPOTIFY->value); $response = Http::asForm()->post('https://accounts.spotify.com/api/token', [ 'grant_type' => 'refresh_token', diff --git a/app/Livewire/Actions/Api/Twitch/RefreshToken.php b/app/Livewire/Actions/Api/Twitch/RefreshToken.php new file mode 100644 index 0000000..ec2e8b0 --- /dev/null +++ b/app/Livewire/Actions/Api/Twitch/RefreshToken.php @@ -0,0 +1,38 @@ +connections->firstWhere('type_id', ConnectionType::TWITCH->value); + + $response = Http::asForm()->post('https://id.twitch.tv/oauth2/token', [ + 'client_id' => config('services.twitch.client_id'), + 'client_secret' => config('services.twitch.client_secret'), + 'refresh_token' => $twitchConnection->refresh_token, + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + return false; + } + + $twitchConnection->update(['token' => $response->json()['access_token']]); + + return true; + } catch (Exception $e) { + Log::error("Could not refresh user: {$user->name} token: {$e->getMessage()}"); + + return false; + } + } +} diff --git a/app/Livewire/Actions/Api/SearchCategories.php b/app/Livewire/Actions/Api/Twitch/SearchCategories.php similarity index 58% rename from app/Livewire/Actions/Api/SearchCategories.php rename to app/Livewire/Actions/Api/Twitch/SearchCategories.php index 7ea7ebb..a7f9e79 100644 --- a/app/Livewire/Actions/Api/SearchCategories.php +++ b/app/Livewire/Actions/Api/Twitch/SearchCategories.php @@ -1,8 +1,8 @@ refreshToken($user); + (new RefreshToken)->handle($user); $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$user->connections->firstWhere('type_id', ConnectionType::TWITCH)->token, + 'Authorization' => 'Bearer '.$user->connections->firstWhere('type_id', ConnectionType::TWITCH->value)->token, 'Content-Type' => 'application/json', 'Client-Id' => config('services.twitch.client_id'), ])->get('https://api.twitch.tv/helix/search/categories', [ @@ -43,33 +43,7 @@ public function search(User $user, string $phrase, int $mediaType) return collect(); } - - public function refreshToken(User $user): bool - { - try { - $twitchConnection = $user->connections->firstWhere('type_id', ConnectionType::TWITCH); - - $response = Http::asForm()->post('https://id.twitch.tv/oauth2/token', [ - 'client_id' => config('services.twitch.client_id'), - 'client_secret' => config('services.twitch.client_secret'), - 'refresh_token' => $twitchConnection->refresh_token, - 'grant_type' => 'refresh_token', - ]); - - if (! $response->successful()) { - return false; - } - - $twitchConnection->update(['token' => $response->json()['access_token']]); - - return true; - } catch (Exception $e) { - Log::error("Could not refresh user: {$user->name} token: {$e->getMessage()}"); - - return false; - } - } - + /* disgusting hack to get high rez images from this endpoint */ private function fix_box_art(string $string): string { diff --git a/app/Livewire/Forms/LoginForm.php b/app/Livewire/Forms/LoginForm.php index c612825..9f5220b 100644 --- a/app/Livewire/Forms/LoginForm.php +++ b/app/Livewire/Forms/LoginForm.php @@ -2,9 +2,8 @@ namespace App\Livewire\Forms; -use App\Libraries\Helpers; -use App\Livewire\Actions\Api\SearchCategories; use App\Livewire\Actions\Api\SearchSpotify; +use App\Livewire\Actions\Api\Twitch\RefreshToken; use Illuminate\Auth\Events\Lockout; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; @@ -46,7 +45,7 @@ public function authenticate(): void auth()->user()->update(['timezone' => timezone()]); (new SearchSpotify)->refreshToken(auth()->user()); - (new SearchCategories)->refreshToken(auth()->user()); + (new RefreshToken)->handle(auth()->user()); } /** diff --git a/app/Models/Connection.php b/app/Models/Connection.php index 739e523..c1b7fa0 100644 --- a/app/Models/Connection.php +++ b/app/Models/Connection.php @@ -15,11 +15,6 @@ class Connection extends Model 'refresh_token', ]; - public function type(): HasOne - { - return $this->hasOne(ConnectionType::class, 'type_id', 'id'); - } - public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/ConnectionType.php b/app/Models/ConnectionType.php deleted file mode 100644 index 64e1848..0000000 --- a/app/Models/ConnectionType.php +++ /dev/null @@ -1,14 +0,0 @@ -=7.3 | ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^5.0|^6.0|^8.0 | ^9.0 | ^10.0", + "phpunit/phpunit": "^8.0|^9.0 | ^10.5 | ^11.5.3", + "roave/security-advisories": "dev-master" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "MarvinLabs\\DiscordLogger\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "MarvinLabs\\DiscordLogger\\": "src/DiscordLogger" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vincent Mimoun-Prat", + "email": "contact@vincentprat.info", + "homepage": "https://vincentprat.info", + "role": "Developer" + } + ], + "description": "Logging to a discord channel in Laravel", + "keywords": [ + "discord", + "laravel", + "logger", + "logging" + ], + "support": { + "issues": "https://github.com/vpratfr/laravel-discord-logger/issues", + "source": "https://github.com/vpratfr/laravel-discord-logger/tree/v1.4.3" + }, + "funding": [ + { + "url": "https://github.com/vpratfr", + "type": "github" + } + ], + "time": "2025-03-04T11:10:14+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/config/logging.php b/config/logging.php index 8d94292..467fa43 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,22 @@ 'path' => storage_path('logs/laravel.log'), ], + 'discord-live-now' => [ + 'driver' => 'custom', + 'via' => MarvinLabs\DiscordLogger\Logger::class, + 'level' => 'debug', + 'url' => env('DISCORD_LIVE_NOW'), + 'ignore_exceptions' => env('LOG_DISCORD_IGNORE_EXCEPTIONS', false), + ], + + 'discord-internal-updates' => [ + 'driver' => 'custom', + 'via' => MarvinLabs\DiscordLogger\Logger::class, + 'level' => 'debug', + 'url' => env('DISCORD_INTERNAL_UPDATES'), + 'ignore_exceptions' => env('LOG_DISCORD_IGNORE_EXCEPTIONS', false), + ], + ], ]; diff --git a/config/services.php b/config/services.php index 18d3379..be64850 100644 --- a/config/services.php +++ b/config/services.php @@ -44,4 +44,9 @@ 'api_key' => env('MOVIEDB_API_KEY'), 'access_token' => env('MOVIEDB_KEY'), ], + + 'discord' => [ + 'internal_updates' => env('DISCORD_INTERNAL_UPDATES'), + 'live_now' => env('DISCORD_LIVE_NOW'), + ], ]; diff --git a/database/migrations/2025_03_25_221715_create_connections_table.php b/database/migrations/2025_03_25_221715_create_connections_table.php index 9934ccb..449ce65 100644 --- a/database/migrations/2025_03_25_221715_create_connections_table.php +++ b/database/migrations/2025_03_25_221715_create_connections_table.php @@ -1,6 +1,6 @@ timestamps(); }); - ConnectionType::create(['name' => 'Spotify']); - ConnectionType::create(['name' => 'Twitch']); + /* ConnectionType::create(['name' => 'Spotify']); + ConnectionType::create(['name' => 'Twitch']); */ Schema::create('connections', function (Blueprint $table) { $table->id(); diff --git a/database/migrations/2026_01_27_033427_drop_connection_types_table.php b/database/migrations/2026_01_27_033427_drop_connection_types_table.php new file mode 100644 index 0000000..c70dde2 --- /dev/null +++ b/database/migrations/2026_01_27_033427_drop_connection_types_table.php @@ -0,0 +1,16 @@ +id(); + $table->string('stream_started_at')->unique(); + $table->timestamp('notified_at'); + $table->timestamps(); + }); + } +}; diff --git a/resources/views/livewire/connection.blade.php b/resources/views/livewire/connection.blade.php index 145e0a8..e7bf137 100644 --- a/resources/views/livewire/connection.blade.php +++ b/resources/views/livewire/connection.blade.php @@ -4,13 +4,13 @@ @php $connections = [ 'spotify' => [ - 'connection' => auth()->user()->connections->firstWhere('type_id', App\Models\ConnectionType::SPOTIFY), + 'connection' => auth()->user()->connections->firstWhere('type_id', App\Enums\ConnectionType::SPOTIFY->value), 'icon' => 'musical-note', 'color' => 'lime', 'name' => 'Spotify' ], 'twitch' => [ - 'connection' => auth()->user()->connections->firstWhere('type_id', App\Models\ConnectionType::TWITCH), + 'connection' => auth()->user()->connections->firstWhere('type_id', App\Enums\ConnectionType::TWITCH->value), 'icon' => 'tv', 'color' => 'purple', 'name' => 'Twitch' diff --git a/routes/console.php b/routes/console.php index 68af6d0..006572f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,11 +1,18 @@ everyMinute(); + Schedule::command('model:prune', [ '--model' => [ \Spatie\Health\Models\HealthCheckResultHistoryItem::class, ], ])->daily(); + +Schedule::command(RefreshTwitchAccessToken::class)->monthly(); + +Schedule::command(CheckTwitchLiveStatus::class)->everyMinute(); From 815a4e70c61a2a71b8b2cbdce6f882d1a810c247 Mon Sep 17 00:00:00 2001 From: kyledoesdev Date: Mon, 26 Jan 2026 23:00:27 -0500 Subject: [PATCH 2/2] Removes debugging statement Removes the `dd()` debugging statement from the getConnectionTypeId method. --- app/Http/Controllers/ConnectionController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/Http/Controllers/ConnectionController.php b/app/Http/Controllers/ConnectionController.php index f382f53..b80bef6 100644 --- a/app/Http/Controllers/ConnectionController.php +++ b/app/Http/Controllers/ConnectionController.php @@ -35,8 +35,6 @@ public function processConnection(Request $request) private function getConnectionTypeId(string $type) { - dd(ConnectionType::TWITCH->slug()); - return match ($type) { 'twitch' => ConnectionType::TWITCH->slug(), 'spotify' => ConnectionType::SPOTIFY->slug()