├── .styleci.yml ├── .gitignore ├── .github └── FUNDING.yml ├── src ├── Exceptions │ ├── AppTokenMissing.php │ ├── WebhookSecretMissing.php │ ├── TwitchEmoteSetIdException.php │ ├── WebhookCallbackMissing.php │ ├── MigrationMissingException.php │ ├── WrongConnectionTypeException.php │ └── WebhookTwitchSignatureMissing.php ├── Console │ ├── Commands │ │ ├── BroadcastFaker │ │ │ ├── StreamOffline.php │ │ │ ├── ChannelFollowFake.php │ │ │ ├── ChannelSubscribeFake.php │ │ │ ├── StreamOnline.php │ │ │ ├── ChannelUpdateFake.php │ │ │ ├── Fake.php │ │ │ ├── ChannelRaidFake.php │ │ │ └── ChannelCheerFake.php │ │ ├── Make │ │ │ ├── Stubs │ │ │ │ ├── BotCommand.stub │ │ │ │ └── BotScheduling.stub │ │ │ ├── MakeBotSchedulingCommand.php │ │ │ └── MakeBotCommandCommand.php │ │ ├── ChatBot │ │ │ ├── RuntimeCommand.php │ │ │ ├── RestartServerCommand.php │ │ │ ├── StartCommand.php │ │ │ └── SendMessageCommand.php │ │ ├── Twitch │ │ │ └── RefresherCommand.php │ │ ├── EventSubListingCommand.php │ │ ├── SecretCommand.php │ │ ├── EventBroadcastFaker.php │ │ └── EventSubDeleteCommand.php │ ├── Scheduling │ │ ├── MadeWithChatBotScheduling.php │ │ └── ChatBotScheduling.php │ └── ConsoleServiceProvider.php ├── Events │ ├── Twitch │ │ ├── RefresherEvent.php │ │ ├── BotTokenExpires.php │ │ ├── EventReceived.php │ │ ├── StreamOnline.php │ │ ├── StreamOffline.php │ │ └── ChatMessageReceived.php │ ├── UserConnectionChanged.php │ └── ViewerEnteredChat.php ├── Facades │ └── OpenOverlay.php ├── Service │ └── Twitch │ │ ├── DateTime.php │ │ ├── ChannelsClient.php │ │ ├── AuthClient.php │ │ ├── StreamsClient.php │ │ ├── CustomRewardsClient.php │ │ ├── SubscriptionsClient.php │ │ ├── UsersClient.php │ │ ├── ApiClient.php │ │ ├── ChatEmotesClient.php │ │ └── EventSubClient.php ├── Models │ ├── User │ │ ├── UserOpenOverlay.php │ │ └── Connection.php │ ├── Twitch │ │ ├── UserFollowers.php │ │ ├── UserSubscriber.php │ │ ├── EventSubEvents.php │ │ ├── EventSubscription.php │ │ └── Emote.php │ └── BotConnection.php ├── OpenOverlay.php ├── ChatBot │ ├── Commands │ │ ├── HelloWorldBotCommand.php │ │ ├── ShoutOutBotCommand.php │ │ ├── SimpleBotCommands.php │ │ └── BotCommand.php │ └── Twitch │ │ ├── ChatMessage.php │ │ └── ConnectionHandler.php ├── Sociallite │ ├── TwitchClientCredentialsProvider.php │ └── TwitchClientCredentialsExtendSocialite.php ├── Listeners │ ├── UpdateUserWebhookCalls.php │ ├── TwitchSplitReceivedEvents.php │ ├── Twitch │ │ ├── Refresher │ │ │ ├── StandardRefresher.php │ │ │ ├── LoginRefresher.php │ │ │ ├── NewConnectionRefresher.php │ │ │ └── Refresher.php │ │ ├── EventListener.php │ │ ├── NewSubscriberListener.php │ │ ├── UpdateBotToken.php │ │ └── NewFollowerListener.php │ └── AutoShoutOutRaid.php ├── Http │ └── Controllers │ │ ├── Connection │ │ ├── AppTokenController.php │ │ ├── BotAuthController.php │ │ ├── SocialiteController.php │ │ └── AuthController.php │ │ └── Api │ │ └── Connection │ │ └── WebhookController.php ├── Support │ ├── StreamerOnline.php │ └── ViewerInChat.php ├── Actions │ └── RegisterUserTwitchWebhooks.php ├── EventServiceProvider.php └── OpenOverlayServiceProvider.php ├── .editorconfig ├── database ├── Factories │ └── EventSubEventsFactory.php └── migrations │ ├── 2021_01_03_000001_add_twitch_user_followers_soft_delete.php │ ├── 2021_01_03_000002_add_twitch_user_subscriber_soft_delete.php │ ├── 2020_12_17_000001_create_user_bots_enabled_table.php │ ├── 2020_12_05_000000_create_twitch_user_followers_table.php │ ├── 2020_12_03_124839_create_twitch_event_sub_events_table.php │ ├── 2020_12_05_000001_create_twitch_user_subscriber_table.php │ ├── 2020_12_17_000000_create_bots_connections_table.php │ ├── 2021_03_06_000001_add_twitch_user_subscriber_additional_fields.php │ └── 2020_11_29_124839_create_users_connections_table.php ├── CHANGELOG.md ├── LICENSE.md ├── contributing.md ├── composer.json ├── routes └── openoverlay.php ├── config └── openoverlay.php └── README.md /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | /phpunit.xml 4 | .phpunit.result.cache 5 | 6 | .idea 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: chris-redbeed 4 | # patreon: 5 | -------------------------------------------------------------------------------- /src/Exceptions/AppTokenMissing.php: -------------------------------------------------------------------------------- 1 | "u1337", 9 | "broadcaster_user_login" => "cooler_user", 10 | "broadcaster_user_name" => "Cooler_user", 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /src/Events/Twitch/RefresherEvent.php: -------------------------------------------------------------------------------- 1 | twitchConnection = $connection; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Facades/OpenOverlay.php: -------------------------------------------------------------------------------- 1 | hasMany(Connection::class); 12 | } 13 | 14 | public function bots() 15 | { 16 | return $this->belongsToMany(BotConnection::class, 'users_bots_enabled', 'user_id', 'bot_id'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/OpenOverlay.php: -------------------------------------------------------------------------------- 1 | hourly(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/Stubs/BotCommand.stub: -------------------------------------------------------------------------------- 1 | hourly(); 18 | } 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Service/Twitch/ChannelsClient.php: -------------------------------------------------------------------------------- 1 | addAppToken() 13 | ->withOptions([ 14 | RequestOptions::QUERY => [ 15 | 'broadcaster_id' => $broadcasterId, 16 | ], 17 | ]) 18 | ->request('GET', 'channels'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ChatBot/Commands/HelloWorldBotCommand.php: -------------------------------------------------------------------------------- 1 | username, 16 | 'It is ' . Carbon::now()->toString() . '... I think', 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Sociallite/TwitchClientCredentialsProvider.php: -------------------------------------------------------------------------------- 1 | 'client_credentials', 18 | ]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Listeners/UpdateUserWebhookCalls.php: -------------------------------------------------------------------------------- 1 | user->connections()->where('service', 'twitch')->first(); 14 | RegisterUserTwitchWebhooks::registerAll($twitchConnection, true); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Events/Twitch/BotTokenExpires.php: -------------------------------------------------------------------------------- 1 | botModel = $botModel; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Sociallite/TwitchClientCredentialsExtendSocialite.php: -------------------------------------------------------------------------------- 1 | extendSocialite('twitch_client_credentials', TwitchClientCredentialsProvider::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/ChannelFollowFake.php: -------------------------------------------------------------------------------- 1 | "1234", 10 | "user_name" => "cool_user", 11 | "broadcaster_user_id" => "1337", 12 | "broadcaster_user_name" => "cooler_user", 13 | ]; 14 | 15 | protected function randomizeEventData(): array 16 | { 17 | $array = parent::randomizeEventData(); 18 | $array['user_name'] = Fake::fakeUsername(); 19 | 20 | return $array; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/Twitch/UserFollowers.php: -------------------------------------------------------------------------------- 1 | 'datetime', 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /src/Models/Twitch/UserSubscriber.php: -------------------------------------------------------------------------------- 1 | "1234", 9 | "user_name" => "cool_user", 10 | "broadcaster_user_id" => "1337", 11 | "broadcaster_user_name" => "cooler_user", 12 | "tier" => "1000", 13 | "is_gift" => false, 14 | ]; 15 | 16 | protected function randomizeEventData(): array 17 | { 18 | $array = parent::randomizeEventData(); 19 | $array['user_name'] = Fake::fakeUsername(); 20 | 21 | return $array; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Console/Commands/ChatBot/RuntimeCommand.php: -------------------------------------------------------------------------------- 1 | loop = Loop::get(); 21 | } 22 | 23 | protected function softShutdown() 24 | { 25 | $this->loop->stop(); 26 | 27 | echo "Chatbot Service will shutdown." . PHP_EOL; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /database/Factories/EventSubEventsFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->unique()->uuid, 20 | 'event_type' => '', 21 | 'event_user_id' => $this->faker->unique()->uuid, 22 | 'event_data' => [], 23 | 'event_sent' => now(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/StreamOnline.php: -------------------------------------------------------------------------------- 1 | "1337", 11 | "broadcaster_user_id" => "u1337", 12 | "broadcaster_user_login" => "cooler_user", 13 | "broadcaster_user_name" => "Cooler_user", 14 | "type" => "live", 15 | "started_at" => null, 16 | ]; 17 | 18 | protected function randomizeEventData(): array 19 | { 20 | $array = parent::randomizeEventData(); 21 | 22 | $array['started_at'] = Carbon::now()->toIso8601String(); 23 | 24 | return $array; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `OpenOverlay` will be documented in this file. 4 | 5 | ## Version 0.4 6 | ### Added 7 | - Bot Commands 8 | - Simple command (by config) 9 | - Advanced command 10 | - Channel Client 11 | 12 | ## Version 0.3 13 | ### Added 14 | - Basic Bot 15 | - Auth URLs 16 | - Endless runtime 17 | - Forward messages to laravel event (also Broadcast) 18 | 19 | ## Version 0.2.1 20 | ### Added 21 | - EventSub Listener 22 | - Add follower 23 | - Add subscriber 24 | 25 | ## Version 0.2 26 | ### Added 27 | - Users Client 28 | - Get Followers 29 | - Get Subscriber 30 | - Rewrite ApiClient 31 | - Add Fake Event command 32 | 33 | ## Version 0.1 34 | 35 | ### Added 36 | - Everything 37 | -------------------------------------------------------------------------------- /src/Listeners/TwitchSplitReceivedEvents.php: -------------------------------------------------------------------------------- 1 | event->event_type === 'stream.online') { 14 | broadcast(new StreamOnline($twitchEvent->event)); 15 | return; 16 | } 17 | 18 | if ($twitchEvent->event->event_type === 'stream.offline') { 19 | broadcast(new StreamOffline($twitchEvent->event)); 20 | return; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Service/Twitch/AuthClient.php: -------------------------------------------------------------------------------- 1 | withOptions([ 16 | RequestOptions::QUERY => [ 17 | 'client_id' => $clientId, 18 | 'client_secret' => $clientSecret, 19 | 'grant_type' => 'refresh_token', 20 | 'refresh_token' => $refreshToken, 21 | ], 22 | ]) 23 | ->request('POST', 'https://id.twitch.tv/oauth2/token'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/migrations/2021_01_03_000001_add_twitch_user_followers_soft_delete.php: -------------------------------------------------------------------------------- 1 | softDeletes(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('twitch_user_followers', function (Blueprint $table) { 29 | $table->dropSoftDeletes(); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/2021_01_03_000002_add_twitch_user_subscriber_soft_delete.php: -------------------------------------------------------------------------------- 1 | softDeletes(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('twitch_user_subscribers', function (Blueprint $table) { 29 | $table->dropSoftDeletes(); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/Commands/Twitch/RefresherCommand.php: -------------------------------------------------------------------------------- 1 | get(); 19 | 20 | foreach ($connections as $connection) { 21 | $this->info('Start for ' . $connection->service_username . ' (' . $connection->service_user_id . ')'); 22 | 23 | event(new RefresherEvent($connection)); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/Refresher/StandardRefresher.php: -------------------------------------------------------------------------------- 1 | twitchConnection->service !== 'twitch') { 17 | return; 18 | } 19 | 20 | if (parent::saveFollowers()) { 21 | $this->refreshFollowers($event->twitchConnection); 22 | } 23 | 24 | if (parent::saveSubscriber()) { 25 | $this->refreshSubscriber($event->twitchConnection); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/Twitch/EventSubEvents.php: -------------------------------------------------------------------------------- 1 | 'datetime', 25 | 'event_data' => 'array', 26 | ]; 27 | 28 | protected static function newFactory() 29 | { 30 | return EventSubEventsFactory::new(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/ChannelUpdateFake.php: -------------------------------------------------------------------------------- 1 | "1337", 12 | "user_name" => "open_overlay_user", 13 | "title" => "Best Stream Ever", 14 | "language" => "en", 15 | "category_id" => "21779", 16 | "category_name" => "Fortnite", 17 | "is_mature" => false, 18 | ]; 19 | 20 | protected function randomizeEventData(): array 21 | { 22 | $array = parent::randomizeEventData(); 23 | 24 | $array['title'] = implode(' ', [ 25 | $array['title'], 26 | '('.Carbon::now()->format('H:i:s').')' 27 | ]); 28 | $array['user_name'] = Fake::fakeUsername(); 29 | 30 | return $array; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/Fake.php: -------------------------------------------------------------------------------- 1 | fakeValue(); 17 | } 18 | 19 | protected function randomizeEventData(): array 20 | { 21 | return $this->eventData; 22 | } 23 | 24 | public function fakeValue(): array 25 | { 26 | return $this->randomizeEventData(); 27 | } 28 | 29 | public static function fakeUsername(): string { 30 | return Arr::random([ 31 | 'Chris', 32 | 'redbeed', 33 | 'moVRs', 34 | 'Lethinium', 35 | 'kekub', 36 | 'Laravel_user', 37 | 'Twitch_user' 38 | ]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/EventListener.php: -------------------------------------------------------------------------------- 1 | event->event_type === $this->eventType(); 19 | } 20 | 21 | public function handle(EventReceived $event) 22 | { 23 | if ($this->eventValid($event) === false) { 24 | return; 25 | } 26 | 27 | $this->handleEvent($event); 28 | } 29 | 30 | 31 | abstract public function handleEvent(EventReceived $event): void; 32 | } 33 | -------------------------------------------------------------------------------- /src/Events/UserConnectionChanged.php: -------------------------------------------------------------------------------- 1 | user = $user; 22 | $this->service = $service; 23 | } 24 | 25 | /** 26 | * Get the channels the event should broadcast on. 27 | * 28 | * @return \Illuminate\Broadcasting\Channel|array 29 | */ 30 | public function broadcastOn() 31 | { 32 | return new PrivateChannel('user.'.$this->user->id.'.connection.changed'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Console/Commands/ChatBot/RestartServerCommand.php: -------------------------------------------------------------------------------- 1 | currentTime() 33 | ); 34 | 35 | $this->info('Broadcasted restart signal to Chat Bot Service!'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/Refresher/LoginRefresher.php: -------------------------------------------------------------------------------- 1 | user 17 | ->connections() 18 | ->where('service', 'twitch') 19 | ->first(); 20 | 21 | if (empty($twitchConnection)) { 22 | return; 23 | } 24 | 25 | if (parent::saveFollowers()) { 26 | $this->refreshFollowers($twitchConnection); 27 | } 28 | 29 | if (parent::saveSubscriber()) { 30 | $this->refreshSubscriber($twitchConnection); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/ChannelRaidFake.php: -------------------------------------------------------------------------------- 1 | "1234", 10 | "from_broadcaster_user_login" => "cool_user", 11 | "from_broadcaster_user_name" => "Cool_User", 12 | "to_broadcaster_user_id" => "1337", 13 | "to_broadcaster_user_login" => "cooler_user", 14 | "to_broadcaster_user_name" => "Cooler_User", 15 | "viewers" => 9001 16 | ]; 17 | 18 | protected function randomizeEventData(): array 19 | { 20 | $array = parent::randomizeEventData(); 21 | 22 | $username = Fake::fakeUsername(); 23 | $array['from_broadcaster_user_login'] = $username; 24 | $array['from_broadcaster_user_name'] = strtolower($username); 25 | 26 | $array['viewers'] = random_int(1, 9999); 27 | 28 | return $array; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Http/Controllers/Connection/AppTokenController.php: -------------------------------------------------------------------------------- 1 | socialite()->getAccessTokenResponse(request()->get('code')); 27 | 28 | if (empty($auth['access_token'])) { 29 | return abort(404, 'access_token not found in body'); 30 | } 31 | 32 | return $auth['access_token']; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Events/ViewerEnteredChat.php: -------------------------------------------------------------------------------- 1 | streamer = $streamer; 20 | $this->username = $username; 21 | } 22 | 23 | public function broadcastAs(): string 24 | { 25 | return 'viewer-chat-entered'; 26 | } 27 | 28 | public function broadcastWith() 29 | { 30 | return [ 31 | 'username' => $this->username, 32 | ]; 33 | } 34 | 35 | public function broadcastOn() 36 | { 37 | return new Channel('twitch.' . $this->streamer->service_user_id); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/Refresher/NewConnectionRefresher.php: -------------------------------------------------------------------------------- 1 | service !== 'twitch') { 17 | return; 18 | } 19 | 20 | $twitchConnection = $event->user 21 | ->connections() 22 | ->where('service', 'twitch') 23 | ->first(); 24 | 25 | if (parent::saveFollowers()) { 26 | $this->refreshFollowers($twitchConnection); 27 | } 28 | 29 | if (parent::saveSubscriber()) { 30 | $this->refreshSubscriber($twitchConnection); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/NewSubscriberListener.php: -------------------------------------------------------------------------------- 1 | event->event_data; 18 | 19 | UserSubscriber::firstOrCreate([ 20 | 'twitch_user_id' => $event->event->event_user_id, 21 | 'subscriber_user_id' => $subscriberData['user_id'], 22 | ], [ 23 | 'subscriber_username' => $subscriberData['user_name'], 24 | 'tier' => $subscriberData['user_name'], 25 | 'tier_name' => $subscriberData['plan_name'], 26 | 'is_gift' => $subscriberData['is_gift'], 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Console/Commands/BroadcastFaker/ChannelCheerFake.php: -------------------------------------------------------------------------------- 1 | false, 10 | "user_id" => "1234", 11 | "user_login" => null, 12 | "user_name" => null, 13 | "broadcaster_user_id" => "1337", 14 | "broadcaster_user_login" => "cooler_user", 15 | "broadcaster_user_name" => "Cooler_User", 16 | "message" => "This is a bit cheer for you!", 17 | "bits" => 1000, 18 | ]; 19 | 20 | protected function randomizeEventData(): array 21 | { 22 | $array = parent::randomizeEventData(); 23 | 24 | $anonymous = (bool)random_int(0, 1); 25 | 26 | if ($anonymous === false) { 27 | $username = Fake::fakeUsername(); 28 | $array['user_name'] = $username; 29 | $array['user_login'] = strtolower($username); 30 | } 31 | 32 | $array['bits'] = random_int(10, 5000); 33 | 34 | return $array; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2020_12_17_000001_create_user_bots_enabled_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('user_id'); 19 | $table->unsignedBigInteger('bot_id'); 20 | $table->timestamps(); 21 | 22 | $table->foreign('user_id')->references('id')->on('users'); 23 | $table->foreign('bot_id')->references('id')->on('bots_connections'); 24 | 25 | $table->unique(['bot_id', 'user_id']); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('users_bots_enabled'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/2020_12_05_000000_create_twitch_user_followers_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('twitch_user_id'); 19 | 20 | $table->string('follower_user_id'); 21 | $table->string('follower_username'); 22 | 23 | $table->timestamp('followed_at')->nullable(); 24 | $table->timestamps(); 25 | 26 | $table->unique(['twitch_user_id', 'follower_user_id']); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('twitch_user_followers'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2020_12_03_124839_create_twitch_event_sub_events_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('event_id'); 19 | 20 | $table->string('event_type'); 21 | $table->string('event_user_id'); 22 | $table->json('event_data'); 23 | 24 | $table->timestamp('event_sent')->nullable(); 25 | $table->timestamps(); 26 | 27 | $table->unique('event_id'); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down() 37 | { 38 | Schema::dropIfExists('twitch_event_sub_events'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Chris Woelk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Models/Twitch/EventSubscription.php: -------------------------------------------------------------------------------- 1 | id = $twitchData['id']; 38 | $model->status = $twitchData['status']; 39 | $model->type = $twitchData['type']; 40 | $model->version = $twitchData['version']; 41 | $model->condition = $twitchData['condition']; 42 | $model->transport = $twitchData['transport']; 43 | $model->createdAt = DateTime::parse($twitchData['created_at']); 44 | 45 | return $model; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Console/Scheduling/ChatBotScheduling.php: -------------------------------------------------------------------------------- 1 | message())) { 20 | return false; 21 | } 22 | 23 | return true; 24 | } 25 | 26 | protected function message(): string 27 | { 28 | return ''; 29 | } 30 | 31 | protected function schedule(Event $event): Event 32 | { 33 | return $event->everyFiveMinutes(); 34 | } 35 | 36 | public function getJob(Schedule $schedule, $user): ?Event 37 | { 38 | if (!$this->valid($user)) { 39 | return null; 40 | } 41 | 42 | return $this->schedule($schedule->command(SendMessageCommand::class, [$user->id, $this->message()])); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /database/migrations/2020_12_05_000001_create_twitch_user_subscriber_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('twitch_user_id'); 19 | 20 | $table->string('subscriber_user_id'); 21 | $table->string('subscriber_username'); 22 | 23 | $table->boolean('is_gift'); 24 | 25 | $table->string('tier'); 26 | $table->string('tier_name'); 27 | 28 | $table->timestamps(); 29 | 30 | $table->unique(['twitch_user_id', 'subscriber_user_id']); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down() 40 | { 41 | Schema::dropIfExists('twitch_user_subscribers'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/UpdateBotToken.php: -------------------------------------------------------------------------------- 1 | botModel->service !== 'twitch' || empty($event->botModel->service_refresh_token)) { 15 | return; 16 | } 17 | 18 | try { 19 | /** @var AuthClient $client */ 20 | $client = AuthClient::http(); 21 | $response = $client->refreshToken($event->botModel->service_refresh_token); 22 | } catch (\Exception $exception) { 23 | Log::error("Bot Connection deleted"); 24 | Log::error($exception); 25 | 26 | return; 27 | } 28 | 29 | $event->botModel->service_token = $response['access_token']; 30 | $event->botModel->service_refresh_token = $response['refresh_token']; 31 | $event->botModel->expires_at = Carbon::now()->addSeconds($response['expires_in']); 32 | $event->botModel->save(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Events/Twitch/EventReceived.php: -------------------------------------------------------------------------------- 1 | event = $event; 22 | } 23 | 24 | public function broadcastOn(): Channel 25 | { 26 | return new Channel('twitch.'.$this->event->event_user_id); 27 | } 28 | 29 | public function broadcastAs(): string 30 | { 31 | return 'event-received'; 32 | } 33 | 34 | public function broadcastWith() 35 | { 36 | return [ 37 | 'type' => $this->event->event_type, 38 | 'data' => $this->event->event_data, 39 | 'created_at' => $this->event->event_sent, 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeBotSchedulingCommand.php: -------------------------------------------------------------------------------- 1 | laravel->basePath(trim($relativePath, '/'))) 23 | ? $customPath 24 | : __DIR__.$relativePath; 25 | } 26 | 27 | protected function getDefaultNamespace($rootNamespace): string 28 | { 29 | return $rootNamespace.'\Bot\Scheduling'; 30 | } 31 | 32 | protected function getArguments(): array 33 | { 34 | return [ 35 | ['name', InputArgument::REQUIRED, 'The name of the bot scheduling message'], 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/2020_12_17_000000_create_bots_connections_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('service', 50)->default('twitch'); 19 | 20 | $table->bigInteger('bot_user_id')->nullable(); 21 | $table->string('bot_username')->nullable(); 22 | 23 | $table->text('service_token'); 24 | $table->text('service_refresh_token')->nullable(); 25 | 26 | $table->timestamp('expires_at')->nullable(); 27 | $table->timestamps(); 28 | 29 | $table->unique(['bot_user_id', 'service']); 30 | }); 31 | } 32 | 33 | /** 34 | * Reverse the migrations. 35 | * 36 | * @return void 37 | */ 38 | public function down() 39 | { 40 | Schema::dropIfExists('bots_connections'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /database/migrations/2021_03_06_000001_add_twitch_user_subscriber_additional_fields.php: -------------------------------------------------------------------------------- 1 | string('subscriber_login_name'); 18 | 19 | $table->string('gifter_user_id'); 20 | $table->string('gifter_username'); 21 | $table->string('gifter_login_name'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::table('twitch_user_subscribers', function (Blueprint $table) { 33 | $table->dropColumn('subscriber_login_name'); 34 | $table->dropColumn('gifter_user_id'); 35 | $table->dropColumn('gifter_username'); 36 | $table->dropColumn('gifter_login_name'); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/2020_11_29_124839_create_users_connections_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->bigInteger('user_id'); 19 | $table->string('service', 50)->default('twitch'); 20 | 21 | $table->bigInteger('service_user_id')->nullable(); 22 | $table->string('service_username')->nullable(); 23 | 24 | $table->text('service_token'); 25 | $table->text('service_refresh_token')->nullable(); 26 | 27 | $table->timestamp('expires_at')->nullable(); 28 | $table->timestamps(); 29 | 30 | $table->unique('user_id', 'service'); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down() 40 | { 41 | Schema::dropIfExists('users_connections'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and will be fully credited. 4 | 5 | Contributions are accepted via Pull Requests on [Github](https://github.com/redbeed/openoverlay). 6 | 7 | # Things you could do 8 | If you want to contribute but do not know where to start, this list provides some starting points. 9 | - Add license text 10 | - Remove rewriteRules.php 11 | - Set up TravisCI, StyleCI, ScrutinizerCI 12 | - Write a comprehensive ReadMe 13 | 14 | ## Pull Requests 15 | 16 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 17 | 18 | - **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. 19 | 20 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 21 | 22 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 23 | 24 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 25 | 26 | 27 | **Happy coding**! 28 | -------------------------------------------------------------------------------- /src/Support/StreamerOnline.php: -------------------------------------------------------------------------------- 1 | service_user_id . '.viewer.' . StreamerOnline::onlineTime($streamer->service_user_id, $platform); 14 | } 15 | 16 | public static function list(Connection $streamer, string $platform = 'twitch'): array 17 | { 18 | return Cache::get(self::cacheKey($streamer, $platform), []); 19 | } 20 | 21 | public static function add(string $username, Connection $streamer, string $platform = 'twitch') 22 | { 23 | $users = self::list($streamer, $platform); 24 | 25 | if (in_array($username, $users)) { 26 | return; 27 | } 28 | 29 | $users[] = $username; 30 | Cache::put(self::cacheKey($streamer, $platform), $users); 31 | 32 | broadcast(new ViewerEnteredChat($username, $streamer)); 33 | } 34 | 35 | public static function clear(Connection $streamer, string $platform = 'twitch') 36 | { 37 | Cache::pull(self::cacheKey($streamer, $platform)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Service/Twitch/StreamsClient.php: -------------------------------------------------------------------------------- 1 | byUserIds([$userId]); 12 | } 13 | 14 | public function byUsername(string $username): array 15 | { 16 | return $this->byUsernames([$username]); 17 | } 18 | 19 | public function byUserIds(array $userIds): array 20 | { 21 | return $this 22 | ->addAppToken() 23 | ->withOptions([ 24 | RequestOptions::QUERY => [ 25 | 'user_id' => array_map(function ($userId) { 26 | return intval($userId); 27 | }, $userIds), 28 | ], 29 | ]) 30 | ->request('GET', 'streams'); 31 | } 32 | 33 | public function byUsernames(array $usernames): array 34 | { 35 | return $this 36 | ->addAppToken() 37 | ->withOptions([ 38 | RequestOptions::QUERY => [ 39 | 'user_login' => array_map(function ($username) { 40 | return strtolower($username); 41 | }, $usernames), 42 | ], 43 | ]) 44 | ->request('GET', 'streams'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Models/Twitch/Emote.php: -------------------------------------------------------------------------------- 1 | id = $emoteData['id']; 34 | $emote->name = $emoteData['name']; 35 | $emote->images = $emoteData['images']; 36 | 37 | if (!empty($emoteData['tier'])) { 38 | $emote->tier = $emoteData['tier']; 39 | } 40 | 41 | if (!empty($emoteData['emote_type'])) { 42 | $emote->emoteType = $emoteData['emote_type']; 43 | } 44 | 45 | if (!empty($emoteData['emote_set_id'])) { 46 | $emote->emoteSetId = $emoteData['emote_set_id']; 47 | } 48 | 49 | return $emote; 50 | } 51 | 52 | public function image(string $size = Emote::IMAGE_SIZE_MD): string 53 | { 54 | return $this->images[$size]; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Service/Twitch/CustomRewardsClient.php: -------------------------------------------------------------------------------- 1 | userConnection = $userConnection; 18 | } 19 | 20 | public function get(string $broadcasterId): array 21 | { 22 | return $this 23 | ->withAppToken($this->userConnection->service_token) 24 | ->withOptions([ 25 | RequestOptions::QUERY => [ 26 | 'broadcaster_id' => $broadcasterId, 27 | ], 28 | ]) 29 | ->request('GET', 'channel_points/custom_rewards'); 30 | } 31 | 32 | public function getRedemptions(string $broadcasterId, string $rewardId): array 33 | { 34 | return $this 35 | ->withAppToken($this->userConnection->service_token) 36 | ->withOptions([ 37 | RequestOptions::QUERY => [ 38 | 'broadcaster_id' => $broadcasterId, 39 | 'reward_id' => $rewardId, 40 | ], 41 | ]) 42 | ->request('GET', 'channel_points/custom_rewards/redemptions'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Http/Controllers/Connection/BotAuthController.php: -------------------------------------------------------------------------------- 1 | add('chat:edit') 20 | ->add('chat:read') 21 | ->unique() 22 | ->toArray(); 23 | } 24 | 25 | public function handleProviderCallback() 26 | { 27 | $botUser = $this->socialite()->user(); 28 | 29 | if (empty($botUser->token)) { 30 | return redirect()->route('dashboard'); 31 | } 32 | 33 | BotConnection::updateOrCreate( 34 | [ 35 | 'service' => 'twitch', 36 | 'bot_user_id' => $botUser->getId(), 37 | ], 38 | [ 39 | 'bot_username' => $botUser->getName(), 40 | 'service_token' => $botUser->token, 41 | 'service_refresh_token' => $botUser->refreshToken, 42 | 'expires_at' => Carbon::now()->addSeconds($botUser->expiresIn), 43 | ] 44 | ); 45 | 46 | return redirect()->route('dashboard'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Http/Controllers/Connection/SocialiteController.php: -------------------------------------------------------------------------------- 1 | socialiteDriver) 18 | ->setScopes($this->scopes()); 19 | } 20 | 21 | protected function scopes(): array { 22 | return config('openoverlay.service.twitch.scopes'); 23 | } 24 | 25 | public function redirect(): RedirectResponse 26 | { 27 | $callbackUrl = $this->callbackUrl(); 28 | 29 | if (!empty($callbackUrl)) { 30 | 31 | /** @var RedirectResponse $redirect */ 32 | $redirect = $this->socialite()->redirect(); 33 | 34 | 35 | $redirectUrl = Url::fromString($redirect->getTargetUrl()); 36 | $redirectUrl = $redirectUrl->withQueryParameter('redirect_uri', $callbackUrl); 37 | 38 | $redirect->setTargetUrl(str_replace('%2B', '+', (string) $redirectUrl)); 39 | 40 | return $redirect; 41 | } 42 | 43 | return $this->socialite()->redirect(); 44 | } 45 | 46 | protected function callbackUrl(): string 47 | { 48 | return ''; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Events/Twitch/StreamOnline.php: -------------------------------------------------------------------------------- 1 | twitchEvent = $twitchEvent; 22 | $this->twitchUser = Connection::where('service_username', $this->twitchEvent->event_user_id)->first(); 23 | 24 | StreamerOnline::setOnline( 25 | $this->twitchEvent->event_user_id, 26 | $this->twitchEvent->event_data['started_at'] 27 | ); 28 | 29 | event(new RefresherEvent($this->twitchUser)); 30 | } 31 | 32 | public function broadcastOn(): Channel 33 | { 34 | return new Channel('twitch.' . $this->twitchUser->service_user_id); 35 | } 36 | 37 | public function broadcastAs(): string 38 | { 39 | return 'stream-online'; 40 | } 41 | 42 | public function broadcastWith() 43 | { 44 | return [ 45 | 'started' => StreamerOnline::onlineTime($this->twitchEvent->event_user_id), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ChatBot/Commands/ShoutOutBotCommand.php: -------------------------------------------------------------------------------- 1 | parameter('username'), '@'); 17 | 18 | $usersClient = new UsersClient(); 19 | try { 20 | $users = $usersClient->byUsername($username); 21 | } catch (ClientException $exception) { 22 | return ''; 23 | } 24 | 25 | $user = head($users['data']); 26 | if ($user['login'] !== strtolower($username)) { 27 | return ''; 28 | } 29 | 30 | $response = [ 31 | 'Don´t forget to checkout ' . $user['display_name'] . ' www.twitch.tv/' . $user['login'] 32 | ]; 33 | 34 | try { 35 | $channelClient = new ChannelsClient(); 36 | $channels = $channelClient->get($user['id']); 37 | $channel = head($channels['data']); 38 | 39 | if (!empty($channel['game_id'])) { 40 | $response[] = '- last playing "' . $channel['game_name'] . '"'; 41 | } 42 | } catch (ClientException $exception) { 43 | // ignore 44 | } 45 | 46 | return implode(' ', $response); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ChatBot/Commands/SimpleBotCommands.php: -------------------------------------------------------------------------------- 1 | simpleCommands = config('openoverlay.bot.commands.simple'); 20 | } 21 | 22 | public function handle(ChatMessage $chatMessage) 23 | { 24 | foreach ($this->simpleCommands as $command => $responseMessage) { 25 | $this->handleSimpleCommand($chatMessage, $command, $responseMessage); 26 | } 27 | } 28 | 29 | public function handleSimpleCommand(ChatMessage $chatMessage, string $command, string $responseMessage): void 30 | { 31 | if ($this->messageStartsWith($chatMessage->message, $command) === false) { 32 | return; 33 | } 34 | 35 | $this->connection->sendChatMessage( 36 | $chatMessage->channel, 37 | $this->responseSimpleCommand($chatMessage, $responseMessage) 38 | ); 39 | } 40 | 41 | public function responseSimpleCommand(ChatMessage $chatMessage, string $message): string 42 | { 43 | $replace = [ 44 | '%username%' => $chatMessage->username, 45 | ]; 46 | 47 | return str_replace(array_keys($replace), array_values($replace), $message); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/NewFollowerListener.php: -------------------------------------------------------------------------------- 1 | event->event_data; 21 | 22 | $followerModal = UserFollowers::withTrashed() 23 | ->where('twitch_user_id', $event->event->event_user_id) 24 | ->where('follower_user_id', $followerData['user_id']) 25 | ->first(); 26 | 27 | if (empty($followerModal)) { 28 | UserFollowers::create([ 29 | 'twitch_user_id' => $event->event->event_user_id, 30 | 'follower_user_id' => $followerData['user_id'], 31 | 'follower_username' => $followerData['user_name'], 32 | 'followed_at' => Carbon::parse($followerData['followed_at']), 33 | 'deleted_at' => null, 34 | ]); 35 | 36 | return; 37 | } 38 | 39 | $followerModal->follower_username = $followerData['user_name']; 40 | $followerModal->followed_at = Carbon::parse($followerData['followed_at']); 41 | 42 | if ($followerModal->trashed()) { 43 | $followerModal->restore(); 44 | } 45 | 46 | $followerModal->save(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Models/BotConnection.php: -------------------------------------------------------------------------------- 1 | 'datetime', 23 | ]; 24 | 25 | public function getServiceTokenAttribute($key): string 26 | { 27 | return $this->decryptString($key); 28 | } 29 | 30 | public function setServiceTokenAttribute($value): void 31 | { 32 | $this->attributes['service_token'] = $this->encryptString($value); 33 | } 34 | 35 | public function getServiceRefreshTokenAttribute($key): string 36 | { 37 | return $this->decryptString($key); 38 | } 39 | 40 | public function setServiceRefreshTokenAttribute($value): void 41 | { 42 | $this->attributes['service_refresh_token'] = $this->encryptString($value); 43 | } 44 | 45 | private function decryptString(string $key): string { 46 | return Crypt::decryptString($key); 47 | } 48 | 49 | private function encryptString(string $value): string { 50 | return Crypt::encryptString($value); 51 | } 52 | 53 | public function users() 54 | { 55 | return $this->belongsToMany(config('auth.providers.users.model'), 'users_bots_enabled', 'bot_id', 'user_id'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redbeed/openoverlay", 3 | "description": "Your self hosted service for twitch web-based overlays and custom bot with Laravel.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Chris Woelk", 8 | "email": "hello@redbeed.com", 9 | "homepage": "https://www.redbeed.com" 10 | } 11 | ], 12 | "homepage": "https://github.com/redbeed/openoverlay", 13 | "keywords": ["Laravel", "OpenOverlay", "twitch", "Eventsub", "Bot", "IRC"], 14 | "require": { 15 | "illuminate/support": "~8|~9", 16 | "guzzlehttp/guzzle": "^7.2", 17 | "ext-json": "*", 18 | "socialiteproviders/twitch": "^5.3", 19 | "react/socket": "^1.6", 20 | "ratchet/pawl": "^0.4.1", 21 | "spatie/url": "^2.0" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "~9.0", 25 | "orchestra/testbench": "~5|~6", 26 | "nunomaduro/phpinsights": "^1.14" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Redbeed\\OpenOverlay\\": "src/", 31 | "Redbeed\\OpenOverlay\\Database\\": "database/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Redbeed\\OpenOverlay\\Tests\\": "tests" 37 | } 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "Redbeed\\OpenOverlay\\OpenOverlayServiceProvider" 43 | ], 44 | "aliases": { 45 | "OpenOverlay": "Redbeed\\OpenOverlay\\Facades\\OpenOverlay" 46 | } 47 | } 48 | }, 49 | "minimum-stability": "dev", 50 | "prefer-stable": true 51 | } 52 | -------------------------------------------------------------------------------- /src/Events/Twitch/StreamOffline.php: -------------------------------------------------------------------------------- 1 | twitchEvent = $twitchEvent; 27 | $this->twitchUser = Connection::where('service_username', $this->twitchEvent->event_user_id)->first(); 28 | $this->streamStarted = StreamerOnline::onlineTime($this->twitchEvent->event_user_id); 29 | 30 | ViewerInChat::clear($this->twitchUser); 31 | StreamerOnline::setOffline($this->twitchEvent->event_user_id); 32 | 33 | event(new RefresherEvent($this->twitchUser)); 34 | } 35 | 36 | public function broadcastOn(): Channel 37 | { 38 | return new Channel('twitch.' . $this->twitchUser->service_user_id); 39 | } 40 | 41 | public function broadcastAs(): string 42 | { 43 | return 'stream-offline'; 44 | } 45 | 46 | public function broadcastWith() 47 | { 48 | return [ 49 | 'started' => $this->streamStarted, 50 | 'ended' => Carbon::now(), 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Models/User/Connection.php: -------------------------------------------------------------------------------- 1 | 'datetime', 25 | ]; 26 | 27 | public function getServiceTokenAttribute($key): string 28 | { 29 | return $this->decryptString($key); 30 | } 31 | 32 | public function setServiceTokenAttribute($value): void 33 | { 34 | $this->attributes['service_token'] = $this->encryptString($value); 35 | } 36 | 37 | public function getServiceRefreshTokenAttribute($key): string 38 | { 39 | return $this->decryptString($key); 40 | } 41 | 42 | public function setServiceRefreshTokenAttribute($value): void 43 | { 44 | $this->attributes['service_refresh_token'] = $this->encryptString($value); 45 | } 46 | 47 | private function decryptString(string $key): string { 48 | return Crypt::decryptString($key); 49 | } 50 | 51 | private function encryptString(string $value): string { 52 | return Crypt::encryptString($value); 53 | } 54 | 55 | public function user() 56 | { 57 | return $this->belongsTo(OpenOverlay::userModel()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Service/Twitch/SubscriptionsClient.php: -------------------------------------------------------------------------------- 1 | withOptions([ 13 | RequestOptions::QUERY => [ 14 | 'broadcaster_id' => $twitchUserId, 15 | ], 16 | ]) 17 | ->request('GET', 'subscriptions'); 18 | } 19 | 20 | public function all(string $twitchUserId): array 21 | { 22 | $firstResponse = $this 23 | ->withOptions([ 24 | RequestOptions::QUERY => [ 25 | 'first' => 100, 26 | ], 27 | ])->list($twitchUserId); 28 | 29 | $subscribers = $firstResponse['data']; 30 | $paginationCursor = $firstResponse['pagination']['cursor'] ?? null; 31 | 32 | while ($paginationCursor !== null) { 33 | $response = $this 34 | ->withOptions([ 35 | RequestOptions::QUERY => [ 36 | 'first' => 100, 37 | 'after' => $paginationCursor, 38 | ], 39 | ]) 40 | ->list($twitchUserId); 41 | 42 | $paginationCursor = $response['pagination']['cursor'] ?? null; 43 | $subscribers = array_merge($subscribers, $response['data'] ?? []); // @todo: replace array_merge because its slow 44 | } 45 | 46 | $firstResponse['data'] = $subscribers; 47 | $firstResponse['pagination'] = []; 48 | 49 | return $firstResponse; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Http/Controllers/Connection/AuthController.php: -------------------------------------------------------------------------------- 1 | socialite()->user(); 22 | 23 | if (empty($socialiteUser->token)) { 24 | return redirect()->route('dashboard'); 25 | } 26 | 27 | /** @var \App\Models\User $user */ 28 | $user = Auth::user(); 29 | 30 | $service = Connection::firstOrCreate( 31 | [ 32 | 'user_id' => $user->id, 33 | 'service' => 'twitch', 34 | ], 35 | [ 36 | 'service_token' => $socialiteUser->token, 37 | ]); 38 | 39 | $service->service_token = $socialiteUser->token; 40 | $service->service_refresh_token = $socialiteUser->refreshToken; 41 | 42 | $service->service_user_id = $socialiteUser->getId(); 43 | $service->service_username = $socialiteUser->getName(); 44 | 45 | $service->expires_at = Carbon::now()->addSeconds($socialiteUser->expiresIn); 46 | $service->save(); 47 | 48 | $user = Auth::user(); 49 | event(new UserConnectionChanged($user, 'twitch')); 50 | 51 | return redirect()->route('dashboard'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeBotCommandCommand.php: -------------------------------------------------------------------------------- 1 | laravel->basePath(trim($relativePath, '/'))) 23 | ? $customPath 24 | : __DIR__.$relativePath; 25 | } 26 | 27 | protected function getDefaultNamespace($rootNamespace): string 28 | { 29 | return $rootNamespace.'\Bot\Commands'; 30 | } 31 | 32 | protected function getArguments(): array 33 | { 34 | return [ 35 | ['name', InputArgument::REQUIRED, 'The name of the bot command (example: !hello)'], 36 | ]; 37 | } 38 | 39 | protected function getOptions(): array 40 | { 41 | return [ 42 | ['command', null, InputOption::VALUE_OPTIONAL, 'The terminal command that should be assigned', 'command:name'], 43 | ]; 44 | } 45 | 46 | protected function replaceClass($stub, $name) 47 | { 48 | $stub = parent::replaceClass($stub, $name); 49 | $command = $this->option('command') ?: Str::snake($this->argument('name'), '-'); 50 | 51 | return str_replace('{{ command }}', $command, $stub); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Console/ConsoleServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerGlobalCommands(); 23 | 24 | if ($this->app->runningInConsole()) { 25 | $this->registerConsoleCommands(); 26 | } 27 | } 28 | 29 | protected function registerConsoleCommands(): void 30 | { 31 | $this->commands([ 32 | EventSubListingCommand::class, 33 | EventSubDeleteCommand::class, 34 | EventBroadcastFaker::class, 35 | SecretCommand::class, 36 | StartCommand::class, 37 | RestartServerCommand::class, 38 | 39 | MakeBotCommandCommand::class, 40 | MakeBotSchedulingCommand::class, 41 | 42 | RefresherCommand::class, 43 | ]); 44 | } 45 | 46 | protected function registerGlobalCommands(): void 47 | { 48 | $this->commands([ 49 | SendMessageCommand::class, 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Events/Twitch/ChatMessageReceived.php: -------------------------------------------------------------------------------- 1 | message = $message; 28 | 29 | /** @var Connection twitchUser */ 30 | $this->twitchUser = Connection::where('service_username', $this->message->channel)->first(); 31 | 32 | $this->viewerInChatListener(); 33 | } 34 | 35 | public function viewerInChatListener() 36 | { 37 | $modules = config('openoverlay.modules', []); 38 | if (empty($modules[ViewerInChat::class])) { 39 | return; 40 | } 41 | 42 | ViewerInChat::add($this->message->username, $this->twitchUser); 43 | } 44 | 45 | public function broadcastOn(): Channel 46 | { 47 | return new Channel('twitch.' . $this->twitchUser->service_user_id); 48 | } 49 | 50 | public function broadcastAs(): string 51 | { 52 | return 'chat-message-received'; 53 | } 54 | 55 | public function broadcastWith() 56 | { 57 | return [ 58 | 'username' => $this->message->username, 59 | 'message' => $this->message->message, 60 | 'message_html' => $this->message->toHtml(Emote::IMAGE_SIZE_MD), 61 | 'channel' => $this->message->channel, 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /routes/openoverlay.php: -------------------------------------------------------------------------------- 1 | group(function () { 11 | 12 | // prefix: /connection 13 | Route::prefix('connection')->group(function () { 14 | 15 | Route::middleware(['web', 'auth'])->group(function () { 16 | 17 | Route::get('/redirect')->uses([AuthController::class, 'redirect']) 18 | ->name('connection.redirect'); 19 | 20 | Route::get('/callback')->uses([AuthController::class, 'handleProviderCallback']) 21 | ->name('connection.callback'); 22 | 23 | // prefix: /connection/app-token 24 | Route::prefix('app-token')->group(function () { 25 | Route::get('/redirect')->uses([AppTokenController::class, 'redirect']) 26 | ->name('connection.app-token.redirect'); 27 | 28 | Route::get('/callback')->uses([AppTokenController::class, 'handleProviderCallback']) 29 | ->name('connection.app-token.callback'); 30 | 31 | }); 32 | 33 | // prefix: /connection/bot 34 | Route::prefix('bot')->group(function () { 35 | Route::get('/redirect')->uses([BotAuthController::class, 'redirect']) 36 | ->name('connection.bot.redirect'); 37 | 38 | Route::get('/callback')->uses([BotAuthController::class, 'handleProviderCallback']) 39 | ->name('connection.bot.callback'); 40 | 41 | }); 42 | }); 43 | 44 | Route::middleware(['api'])->group(function () { 45 | Route::any('/webhook')->uses([WebhookController::class, 'handleProviderCallback']) 46 | ->name('connection.webhook'); 47 | }); 48 | 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/ChatBot/Twitch/ChatMessage.php: -------------------------------------------------------------------------------- 1 | channel = $channel; 25 | $this->username = trim($username); 26 | $this->message = trim($message); 27 | } 28 | 29 | public static function parseIRCMessage(string $message): ?ChatMessage 30 | { 31 | try { 32 | preg_match("/:(.*)\!.*#(\S+) :(.*)/", $message, $matches); 33 | 34 | return new ChatMessage($matches[2], $matches[1], $matches[3]); 35 | } catch (\Exception $exception) { 36 | echo $exception->getMessage() . "\r\n"; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | public function toHtml(string $emoteSize = Emote::IMAGE_SIZE_MD): string 43 | { 44 | $emoteList = collect($this->possibleEmotes) 45 | ->map(function (Emote $emote) use ($emoteSize) { 46 | $name = htmlspecialchars_decode($emote->name); 47 | $regex = '/'.preg_quote($name, '/') . '(\s|$)/'; 48 | 49 | if (@preg_match($regex, null) === false) { 50 | echo "Emote Regex '" . $regex . "' is invalid \r\n"; 51 | return null; 52 | } 53 | 54 | return [ 55 | 'name' => $regex, 56 | 'image' => '' . Str::slug($emote->name) . ' ', 57 | ]; 58 | }); 59 | 60 | return preg_replace( 61 | $emoteList->pluck('name')->filter()->toArray(), 62 | $emoteList->pluck('image')->filter()->toArray(), 63 | $this->message 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Console/Commands/EventSubListingCommand.php: -------------------------------------------------------------------------------- 1 | subscriptions(); 47 | 48 | $this->subscriptionsTable($subscriptions); 49 | $this->info('Total EventSub subscriptions: '.$subscriptions->count()); 50 | } 51 | 52 | protected function subscriptionsTable(Collection $subscriptions): void 53 | { 54 | $subscriptions = $subscriptions->map(function ($subscription) { 55 | /** @var EventSubscription $subscription */ 56 | 57 | return [ 58 | 'id' => $subscription->id, 59 | 'status' => $subscription->status, 60 | 'type' => $subscription->type, 61 | 'condition' => json_encode($subscription->condition), 62 | 'created_at' => $subscription->createdAt->toDateTimeLocalString(), 63 | ]; 64 | }); 65 | 66 | $this->table( 67 | ['Id', 'Status', 'Type', 'Condition', 'Created at'], 68 | $subscriptions, 69 | 'box' 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Listeners/AutoShoutOutRaid.php: -------------------------------------------------------------------------------- 1 | event->event_type !== 'channel.raid') { 19 | return; 20 | } 21 | 22 | /** @var Connection $connection */ 23 | $connection = Connection::where('service_user_id', $event->event->event_user_id) 24 | ->first(); 25 | 26 | if (!$connection) { 27 | return; 28 | } 29 | 30 | $chatMessage = config( 31 | 'openoverlay.modules' . AutoShoutOutRaid::class . 'message', 32 | 'Follow :username over at :twitchUrl. They were last playing :gameName' 33 | ); 34 | 35 | $eventData = $event->event->event_data; 36 | $gameName = ''; 37 | 38 | try { 39 | $channelClient = new ChannelsClient(); 40 | $channels = $channelClient->get($eventData['from_broadcaster_user_id']); 41 | $channel = head($channels['data']); 42 | 43 | if (!empty($channel['game_id'])) { 44 | $gameName = $channel['game_name']; 45 | } 46 | } catch (ClientException $exception) { 47 | Log::debug($exception); 48 | // ignore 49 | } 50 | 51 | Artisan::call(SendMessageCommand::class, [ 52 | 'userId' => $connection->user->id, 53 | 'message' => __($chatMessage, [ 54 | 'username' => $eventData['from_broadcaster_user_name'], 55 | 'twitchUrl' => 'https://www.twitch.tv/' . $eventData['from_broadcaster_user_login'], 56 | 'gameName' => $gameName, 57 | ]), 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Actions/RegisterUserTwitchWebhooks.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 20 | $this->apiClient = EventSubClient::http(); 21 | } 22 | 23 | public static function registerAll(Connection $connection, bool $clearBeforeRegister = false) 24 | { 25 | $webhooks = config('openoverlay.webhook.twitch.subscribe'); 26 | $handler = new self($connection); 27 | 28 | if ($clearBeforeRegister === true) { 29 | $handler->clearBroadcasterSubscriptions(); 30 | } 31 | 32 | foreach ($webhooks as $webhookName) { 33 | $handler->register($webhookName); 34 | } 35 | } 36 | 37 | public function clearBroadcasterSubscriptions() 38 | { 39 | $this->apiClient->deleteSubByBroadcasterId((string)$this->connection->service_user_id); 40 | } 41 | 42 | public function register(string $type): bool 43 | { 44 | $version = '1'; 45 | 46 | $jsonResponse = $this->apiClient->subscribe( 47 | $type, 48 | route('open_overlay.connection.webhook'), 49 | $this->registerCondition($type), 50 | $version 51 | ); 52 | 53 | $subscribeStatus = Arr::first($jsonResponse['data']); 54 | 55 | if ($subscribeStatus['status'] === 'webhook_callback_verification_pending') { 56 | return true; 57 | } 58 | 59 | return false; 60 | } 61 | 62 | private function registerCondition($type): array 63 | { 64 | $broadcasterId = (string)$this->connection->service_user_id; 65 | 66 | if ($type === 'channel.raid') { 67 | return ['to_broadcaster_user_id' => $broadcasterId]; 68 | } 69 | 70 | return ['broadcaster_user_id' => $broadcasterId]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Console/Commands/SecretCommand.php: -------------------------------------------------------------------------------- 1 | option('force')) { 23 | $this->warn('You already have a secret'); 24 | return; 25 | } 26 | 27 | $secretKey = $this->generateSecretKey(); 28 | 29 | $this->showOption($secretKey); 30 | $this->writeSecretKeyInEnvironmentFile($secretKey); 31 | } 32 | 33 | private function generateSecretKey(): string 34 | { 35 | return Str::random(20); 36 | } 37 | 38 | private function writeSecretKeyInEnvironmentFile($key): void 39 | { 40 | $envFilePath = $this->laravel->environmentFilePath(); 41 | $envFileContent = file_get_contents($envFilePath); 42 | $secretKeyPattern = $this->keyReplacementPattern(); 43 | 44 | if (preg_match($secretKeyPattern, $envFileContent) === 0) { 45 | file_put_contents( 46 | $envFilePath, 47 | self::ENV_KEY . '=' . $key . PHP_EOL, 48 | FILE_APPEND 49 | ); 50 | 51 | $this->info('New Secret Key added'); 52 | 53 | return; 54 | } 55 | 56 | file_put_contents( 57 | $envFilePath, 58 | preg_replace( 59 | $secretKeyPattern, 60 | self::ENV_KEY . '=' . $key, 61 | $envFileContent 62 | ) 63 | ); 64 | 65 | $this->info('New Secret Key replaced'); 66 | } 67 | 68 | private function showOption(string $key): void 69 | { 70 | if ($this->option('show')) { 71 | $this->info('New Secret Key:'); 72 | $this->info(self::ENV_KEY . '=' . $key); 73 | } 74 | } 75 | 76 | protected function keyReplacementPattern(): string 77 | { 78 | $escaped = preg_quote('=' . env(self::ENV_KEY, ''), '/'); 79 | return "/^" . self::ENV_KEY . "{$escaped}/m"; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Service/Twitch/UsersClient.php: -------------------------------------------------------------------------------- 1 | addAppToken() 14 | ->withOptions([ 15 | RequestOptions::QUERY => [ 16 | 'id' => $id, 17 | ], 18 | ]) 19 | ->request('GET', 'users'); 20 | } 21 | 22 | public function byUsername(string $username): array 23 | { 24 | return $this 25 | ->addAppToken() 26 | ->withOptions([ 27 | RequestOptions::QUERY => [ 28 | 'login' => $username, 29 | ], 30 | ]) 31 | ->request('GET', 'users'); 32 | } 33 | 34 | public function followers(string $twitchUserId): array 35 | { 36 | return $this 37 | ->addAppToken() 38 | ->withOptions([ 39 | RequestOptions::QUERY => [ 40 | 'to_id' => $twitchUserId, 41 | ], 42 | ]) 43 | ->request('GET', 'users/follows'); 44 | } 45 | 46 | public function allFollowers(string $twitchUserId): array 47 | { 48 | $firstResponse = $this 49 | ->addAppToken() 50 | ->withOptions([ 51 | RequestOptions::QUERY => [ 52 | 'first' => 100, 53 | ], 54 | ]) 55 | ->followers($twitchUserId); 56 | 57 | $totalFollowers = $firstResponse['total']; 58 | $followers = $firstResponse['data']; 59 | $paginationCursor = $firstResponse['pagination']['cursor'] ?? null; 60 | 61 | while ($totalFollowers > count($followers) || $paginationCursor !== null) { 62 | $response = $this 63 | ->addAppToken() 64 | ->withOptions([ 65 | RequestOptions::QUERY => [ 66 | 'first' => 100, 67 | 'after' => $paginationCursor, 68 | ], 69 | ]) 70 | ->followers($twitchUserId); 71 | 72 | $paginationCursor = $response['pagination']['cursor'] ?? null; 73 | $followers = array_merge($followers, $response['data'] ?? []); // @todo: replace array_merge because its slow 74 | } 75 | 76 | $firstResponse['data'] = $followers; 77 | $firstResponse['pagination'] = []; 78 | 79 | return $firstResponse; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Http/Controllers/Api/Connection/WebhookController.php: -------------------------------------------------------------------------------- 1 | headers->get('Twitch-Eventsub-Message-Signature', ''); 18 | $messageId = $request->headers->get('Twitch-Eventsub-Message-Id', ''); 19 | $messageTimestamp = $request->headers->get('Twitch-Eventsub-Message-Timestamp', ''); 20 | $messageType = $request->headers->get('Twitch-Eventsub-Subscription-Type', ''); 21 | $requestBody = $request->getContent(); 22 | 23 | if (EventSubClient::verifySignature($messageSignature, $messageId, $messageTimestamp, $requestBody) === false) { 24 | return response('Not Valid', Response::HTTP_UNAUTHORIZED); 25 | } 26 | 27 | $event = $request->get('event'); 28 | if (!empty($event) && in_array($messageType, config('openoverlay.webhook.twitch.subscribe'), true)) { 29 | 30 | return $this->receiveNotification( 31 | $messageId, 32 | $messageType, 33 | $messageTimestamp, 34 | $event 35 | ); 36 | 37 | } 38 | 39 | return $request->get('challenge'); 40 | } 41 | 42 | private function receiveNotification(string $eventId, string $eventType, string $eventTimestamp, array $eventData) 43 | { 44 | if (empty($eventId) || empty($eventType)) { 45 | return \response('Event id or type not valid', Response::HTTP_BAD_REQUEST); 46 | } 47 | 48 | $newEvent = EventSubEvents::firstOrCreate( 49 | [ 50 | 'event_id' => $eventId, 51 | ], 52 | [ 53 | 'event_type' => $eventType, 54 | 'event_user_id' => $eventData['broadcaster_user_id'] ?? $eventData['to_broadcaster_user_id'], 55 | 'event_data' => $eventData, 56 | 'event_sent' => DateTime::parse($eventTimestamp), 57 | ] 58 | ); 59 | 60 | if ($newEvent->wasRecentlyCreated) { 61 | broadcast(new EventReceived($newEvent)); 62 | } 63 | 64 | return \response('Event received', $newEvent->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK); 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 27 | TwitchExtendSocialite::class, 28 | TwitchClientCredentialsExtendSocialite::class, 29 | ], 30 | BotTokenExpires::class => [ 31 | UpdateBotToken::class 32 | ] 33 | ]; 34 | 35 | public function listens(): array 36 | { 37 | $this->autoShoutOutListener(); 38 | $listen = $this->listen; 39 | 40 | $listen[UserConnectionChanged::class] = [ 41 | UpdateUserWebhookCalls::class, 42 | ]; 43 | 44 | $listen[EventReceived::class][] = TwitchSplitReceivedEvents::class; 45 | $listen[UserConnectionChanged::class][] = NewConnectionRefresher::class; 46 | $listen[RefresherEvent::class][] = StandardRefresher::class; 47 | 48 | if (config('openoverlay.service.twitch.save.follower', false) === true) { 49 | $listen[EventReceived::class][] = NewFollowerListener::class; 50 | } 51 | 52 | if (config('openoverlay.service.twitch.save.subscriber', false) === true) { 53 | $listen[EventReceived::class][] = NewSubscriberListener::class; 54 | } 55 | 56 | return $listen; 57 | } 58 | 59 | public function autoShoutOutListener() 60 | { 61 | $modules = config('openoverlay.modules', []); 62 | if (empty($modules[AutoShoutOutRaid::class]) || empty($modules[AutoShoutOutRaid::class]['message'])) { 63 | return; 64 | } 65 | 66 | $this->listen[EventReceived::class][] = AutoShoutOutRaid::class; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Console/Commands/ChatBot/StartCommand.php: -------------------------------------------------------------------------------- 1 | configureRestartTimer(); 41 | $this->configureChatbot(); 42 | } 43 | 44 | private function configureRestartTimer() 45 | { 46 | $this->lastRestart = $this->getLastShutdown(); 47 | 48 | $this->loop->addPeriodicTimer(10, function () { 49 | if ($this->lastRestart !== $this->getLastShutdown()) { 50 | $this->softShutdown(); 51 | } 52 | }); 53 | } 54 | 55 | private function configureChatbot() 56 | { 57 | /** @var BotConnection $bot */ 58 | $bot = BotConnection::first(); 59 | 60 | connect(ConnectionHandler::TWITCH_IRC_URL, [], [], $this->loop) 61 | ->then(function (WebSocket $conn) use ($bot) { 62 | $connectionHandler = ConnectionHandler::withPrivateMessageHandler($conn); 63 | $connectionHandler->auth($bot); 64 | 65 | foreach ($bot->users as $user) { 66 | $twitchUsers = $user->connections()->where('service', 'twitch')->get(); 67 | 68 | $connectionHandler->initCustomCommands(); 69 | 70 | foreach ($twitchUsers as $twitchUser) { 71 | $connectionHandler->joinChannel($twitchUser); 72 | $connectionHandler->sendChatMessage($twitchUser->service_username, 'Hello'); 73 | } 74 | } 75 | 76 | $conn->on('close', function ($code = null, $reason = null) { 77 | echo "Connection closed ({$code} - {$reason})"; 78 | }); 79 | 80 | }, function ($e) { 81 | echo "Could not connect: {$e->getMessage()}\n"; 82 | }); 83 | } 84 | 85 | public function getLastShutdown() 86 | { 87 | return Cache::get(self::RESTART_CACHE_KEY, 0); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Console/Commands/EventBroadcastFaker.php: -------------------------------------------------------------------------------- 1 | ChannelUpdateFake::class, 36 | 'channel.follow' => ChannelFollowFake::class, 37 | 'channel.subscribe' => ChannelSubscribeFake::class, 38 | 'channel.cheer' => ChannelCheerFake::class, 39 | 'channel.raid' => ChannelRaidFake::class, 40 | 'stream.online' => StreamOnline::class, 41 | 'stream.offline' => StreamOffline::class, 42 | ]; 43 | 44 | /** 45 | * Create a new command instance. 46 | * 47 | * @return void 48 | */ 49 | public function __construct() 50 | { 51 | parent::__construct(); 52 | } 53 | 54 | public function handle(): void 55 | { 56 | $twitchUserId = $this->argument('twitchUserId'); 57 | $type = $this->argument('type'); 58 | 59 | if (empty($twitchUserId)) { 60 | $this->error('Twitch user id not given'); 61 | 62 | return; 63 | } 64 | 65 | if (!array_key_exists($type, $this->types)) { 66 | $this->error('Type is not provided'); 67 | 68 | return; 69 | } 70 | 71 | /** @var Fake $fakeModel */ 72 | $fakeModel = $this->types[$type]; 73 | $fakeEvent = EventSubEvents::factory()->create([ 74 | 'event_user_id' => $twitchUserId, 75 | 'event_type' => $type, 76 | 'event_data' => $fakeModel::value(), 77 | ]); 78 | 79 | broadcast(new EventReceived($fakeEvent)); 80 | $this->info('Event ' . $type . ' for ' . $twitchUserId . ' fired'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/OpenOverlayServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom(__DIR__.'/../resources/lang', 'redbeed'); 21 | // $this->loadViewsFrom(__DIR__.'/../resources/views', 'redbeed'); 22 | 23 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 24 | $this->loadRoutesFrom(__DIR__ . '/../routes/openoverlay.php'); 25 | 26 | // Publishing is only necessary when using the CLI. 27 | if ($this->app->runningInConsole()) { 28 | $this->bootForConsole(); 29 | } 30 | } 31 | 32 | /** 33 | * Register any package services. 34 | * 35 | * @return void 36 | */ 37 | public function register(): void 38 | { 39 | $this->app->register(EventServiceProvider::class); 40 | $this->app->register(ConsoleServiceProvider::class); 41 | 42 | $this->mergeConfigFrom(__DIR__ . '/../config/openoverlay.php', 'openoverlay'); 43 | 44 | // Register the service the package provides. 45 | $this->app->singleton('openoverlay', function ($app) { 46 | return new OpenOverlay; 47 | }); 48 | } 49 | 50 | /** 51 | * Get the services provided by the provider. 52 | * 53 | * @return array 54 | */ 55 | public function provides() 56 | { 57 | return ['openoverlay']; 58 | } 59 | 60 | 61 | /** 62 | * Console-specific booting. 63 | * 64 | * @return void 65 | */ 66 | protected function bootForConsole(): void 67 | { 68 | // Publishing the configuration file. 69 | $this->publishes([ 70 | __DIR__ . '/../config/openoverlay.php' => config_path('openoverlay.php'), 71 | ], 'openoverlay.config'); 72 | 73 | $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { 74 | $this->registerSchedule($schedule); 75 | }); 76 | } 77 | 78 | private function registerSchedule(Schedule $schedule): void 79 | { 80 | /** @var ChatBotScheduling[] $scheduledMessages */ 81 | $scheduledMessages = config('openoverlay.bot.schedules', []); 82 | 83 | /** @var BotConnection[] $bots */ 84 | $bots = BotConnection::all(); 85 | foreach ($bots as $bot) { 86 | foreach ($bot->users as $user) { 87 | foreach ($scheduledMessages as $message) { 88 | 89 | (new $message())->getJob($schedule, $user); 90 | 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Service/Twitch/ApiClient.php: -------------------------------------------------------------------------------- 1 | httpClient = new Client([ 25 | 'base_uri' => $this->baseUrl, 26 | RequestOptions::HEADERS => [ 27 | 'Client-ID' => $clientId, 28 | 'Accept' => 'application/json', 29 | 'Authorization' => 'Bearer ' . $authCode, 30 | ], 31 | ]); 32 | } 33 | 34 | /** 35 | * @return ApiClient 36 | */ 37 | public static function http() 38 | { 39 | return new static(); 40 | } 41 | 42 | /** 43 | * @return static 44 | * @throws AppTokenMissing 45 | */ 46 | public function addAppToken() 47 | { 48 | $appToken = config('openoverlay.webhook.twitch.app_token.token'); 49 | 50 | if (empty($appToken)) { 51 | throw new AppTokenMissing('App Token is needed'); 52 | } 53 | 54 | return $this->withOptions([ 55 | RequestOptions::HEADERS => [ 56 | 'Authorization' => 'Bearer ' . $appToken, 57 | ], 58 | ]); 59 | } 60 | 61 | /** 62 | * @param string $appToken 63 | * 64 | * @return static 65 | */ 66 | public function withAppToken(string $appToken) 67 | { 68 | return $this->setOptions([ 69 | RequestOptions::HEADERS => [ 70 | 'Authorization' => 'Bearer ' . $appToken, 71 | ], 72 | ]); 73 | } 74 | 75 | /** 76 | * @param array $options 77 | * 78 | * @return static 79 | */ 80 | public function withOptions(array $options) 81 | { 82 | $self = clone $this; 83 | $self->options = array_replace_recursive($this->options, $options); 84 | 85 | return $self; 86 | } 87 | 88 | /** 89 | * @param array $options 90 | * 91 | * @return static 92 | */ 93 | public function setOptions(array $options): self 94 | { 95 | $self = clone $this; 96 | $self->options = $options; 97 | 98 | return $self; 99 | } 100 | 101 | /** 102 | * @param string $method 103 | * @param string $url 104 | * 105 | * @return array 106 | * @throws \GuzzleHttp\Exception\GuzzleException 107 | */ 108 | public function request(string $method, string $url) 109 | { 110 | $response = $this->httpClient->request($method, $url, $this->options); 111 | $json = (string)$response->getBody(); 112 | 113 | return json_decode($json, true); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /config/openoverlay.php: -------------------------------------------------------------------------------- 1 | null, 5 | 6 | 'service' => [ 7 | 'twitch' => [ 8 | 9 | /** 10 | * Define which scopes should be grant while connection a twitch user 11 | * Available scopes: https://dev.twitch.tv/docs/authentication/#scopes 12 | */ 13 | 'scopes' => [ 14 | 'user:read:email', 'user:read:broadcast', 15 | 'channel:read:subscriptions', 'channel:read:subscriptions', 16 | 'bits:read', 17 | ], 18 | 19 | /** 20 | * OpenOverlay automatically transfer twitch users data into the database 21 | * Here you can enable and disable different data endpoints 22 | */ 23 | 'save' => [ 24 | 'follower' => true, 25 | 'subscriber' => true, 26 | ], 27 | ], 28 | ], 29 | 30 | 'modules' => [ 31 | /** 32 | * Auto shout out after a raid. 33 | * You can use :username, :twitchUrl and :gameName for your message. 34 | */ 35 | \Redbeed\OpenOverlay\Listeners\AutoShoutOutRaid::class => [ 36 | 'message' => 'Follow :username over at :twitchUrl. They were last playing :gameName' 37 | ], 38 | 39 | \Redbeed\OpenOverlay\Support\ViewerInChat::class => [ 40 | 'reset' => -1 41 | ] 42 | ], 43 | 44 | 'webhook' => [ 45 | 'twitch' => [ 46 | 47 | /** 48 | * The App Token is used to communicate with the Twitch EventSub Api 49 | * If you need to generate a new "app_token.token", you need to set "regenerate" to true 50 | */ 51 | 'app_token' => [ 52 | 'token' => env('OVERLAY_TWITCH_APP_TOKEN'), 53 | 'regenerate' => false, 54 | ], 55 | 56 | /** 57 | * Your personal and unique secret is used to validate a twitch callback 58 | * If you change your secret all previous configures webhook callbacks will be end as invalid 59 | */ 60 | 'secret' => env('OVERLAY_SECRET'), 61 | 62 | /** 63 | * You can subscribe different endpoints/changes on twitch side. 64 | * Available endpoints: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelupdate 65 | */ 66 | 'subscribe' => [ 67 | 'stream.online', 'stream.offline', 68 | 'channel.update', 'channel.follow', 69 | 'channel.subscribe', 'channel.cheer', 70 | ], 71 | ], 72 | ], 73 | 74 | 'bot' => [ 75 | 'commands' => [ 76 | 77 | 'simple' => [ 78 | '!hello' => 'Hello %username%! How are you doing?', 79 | ], 80 | 81 | 'advanced' => [ 82 | \Redbeed\OpenOverlay\ChatBot\Commands\HelloWorldBotCommand::class, 83 | \Redbeed\OpenOverlay\ChatBot\Commands\ShoutOutBotCommand::class, 84 | ] 85 | ], 86 | 87 | 'schedules' => [ 88 | \Redbeed\OpenOverlay\Console\Scheduling\MadeWithChatBotScheduling::class, 89 | ] 90 | ] 91 | ]; 92 | -------------------------------------------------------------------------------- /src/ChatBot/Commands/BotCommand.php: -------------------------------------------------------------------------------- 1 | connection = $connectionHandler; 25 | } 26 | 27 | public function handle(ChatMessage $chatMessage) 28 | { 29 | if ($this->messageValid($chatMessage->message) === false) { 30 | return; 31 | } 32 | 33 | // build & check parameters 34 | $this->buildParameters($chatMessage->message); 35 | if ($this->parametersValid() === false) { 36 | return; 37 | } 38 | 39 | $this->connection->sendChatMessage( 40 | $chatMessage->channel, 41 | $this->response($chatMessage) 42 | ); 43 | } 44 | 45 | protected function parametersValid(): bool 46 | { 47 | $keys = $this->parametersKeys(); 48 | 49 | return count($keys) === count($this->parameters); 50 | } 51 | 52 | protected function buildParameters(string $message) 53 | { 54 | $keys = $this->parametersKeys(); 55 | if (count($keys) <= 0) { 56 | return; 57 | } 58 | 59 | $valuesOnly = explode(' ', $message, 2); 60 | if (count($valuesOnly) !== 2) { 61 | return; 62 | } 63 | 64 | $values = explode(' ', $valuesOnly[1], count($keys)); 65 | foreach ($values as $valueKey => $value) { 66 | $this->parameters[$keys[$valueKey]] = $value; 67 | } 68 | } 69 | 70 | protected function parameter(string $key): ?string 71 | { 72 | if (isset($this->parameters[$key])) { 73 | return $this->parameters[$key]; 74 | } 75 | 76 | return null; 77 | } 78 | 79 | public function parametersKeys(): array 80 | { 81 | preg_match_all("/\{(.+?)\}/m", $this->signature, $matches); 82 | if (count($matches) < 2) { 83 | return []; 84 | } 85 | 86 | return $matches[1]; 87 | } 88 | 89 | public function response(ChatMessage $chatMessage): string 90 | { 91 | return ''; 92 | } 93 | 94 | protected function command(): string 95 | { 96 | return head(explode(' ', $this->signature)); 97 | } 98 | 99 | protected function messageValid(string $message): bool 100 | { 101 | if ($this->messageStartsWith($message, $this->command())) { 102 | return true; 103 | } 104 | 105 | if (is_array($this->aliasCommands) && count($this->aliasCommands)) { 106 | foreach ($this->aliasCommands as $aliasCommand) { 107 | if ($this->messageStartsWith($message, $aliasCommand)) { 108 | return true; 109 | } 110 | } 111 | } 112 | 113 | return false; 114 | } 115 | 116 | protected function messageStartsWith(string $message, string $command): bool 117 | { 118 | // perfect match 119 | if (trim($command) === trim($message)) { 120 | return true; 121 | } 122 | 123 | // match with space 124 | return substr(trim($message), 0, strlen(trim($command) . ' ')) === trim($command) . ' '; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Console/Commands/ChatBot/SendMessageCommand.php: -------------------------------------------------------------------------------- 1 | configureMaxRuntime(); 32 | 33 | $user = $this->getUser(); 34 | $message = $this->argument('message'); 35 | 36 | if (trim($message) === null) { 37 | $this->error('Message not filled'); 38 | return; 39 | } 40 | 41 | if ($user === null) { 42 | $this->error('User not found'); 43 | return; 44 | } 45 | 46 | $bot = $this->getBot(); 47 | 48 | if ($bot === null) { 49 | $this->error('Bot not found'); 50 | return; 51 | } 52 | 53 | connect(ConnectionHandler::TWITCH_IRC_URL, [], [], $this->loop) 54 | ->then(function (WebSocket $conn) use ($bot, $user, $message) { 55 | $connectionHandler = new ConnectionHandler($conn); 56 | $connectionHandler->auth($bot); 57 | 58 | /** @var Connection[] $twitchUsers */ 59 | $twitchUsers = $user->connections()->where('service', 'twitch')->get(); 60 | 61 | foreach ($twitchUsers as $twitchUser) { 62 | 63 | $connectionHandler->joinChannel($twitchUser); 64 | $connectionHandler->sendChatMessage($twitchUser->service_username, $message); 65 | 66 | // close connection after message send (5s delay) 67 | $connectionHandler->addJoinedCallBack($twitchUser->service_username, function () { 68 | 69 | // add delay to make sure the messages send correctly 70 | $this->loop->addTimer(5, function () { 71 | $this->softShutdown(); 72 | }); 73 | }); 74 | } 75 | 76 | }, function ($e) { 77 | echo "Could not connect: {$e->getMessage()}\n"; 78 | }); 79 | } 80 | 81 | private function configureMaxRuntime() 82 | { 83 | $maxRuntime = $this->option('max-runtime'); 84 | 85 | $this->loop->addPeriodicTimer($maxRuntime, function () { 86 | $this->softShutdown(); 87 | }); 88 | } 89 | 90 | private function getUser() 91 | { 92 | $userId = $this->argument('userId'); 93 | return (OpenOverlay::userModel())::find($userId); 94 | } 95 | 96 | private function getBot(): ?BotConnection 97 | { 98 | /** @var UserOpenOverlay $user */ 99 | $user = $this->getUser(); 100 | $botId = $this->option('botId'); 101 | 102 | if ($botId === null) { 103 | return $user->bots()->where('service', 'twitch')->first(); 104 | } 105 | 106 | return $user->bots()->where('id', $botId)->first(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Service/Twitch/ChatEmotesClient.php: -------------------------------------------------------------------------------- 1 | addAppToken() 26 | ->withOptions([ 27 | RequestOptions::QUERY => [ 28 | 'broadcaster_id' => $broadcasterId, 29 | ], 30 | ]) 31 | ->request('GET', 'chat/emotes'); 32 | 33 | if (empty($json['data'])) { 34 | return []; 35 | } 36 | 37 | return $this->parseEmoteList($json['data']); 38 | } 39 | 40 | /** 41 | * @param string $broadcasterId 42 | * @return Emote[] 43 | * @throws \GuzzleHttp\Exception\GuzzleException 44 | * @throws \Redbeed\OpenOverlay\Exceptions\AppTokenMissing 45 | */ 46 | public function global(): array 47 | { 48 | $json = $this 49 | ->addAppToken() 50 | ->request('GET', 'chat/emotes/global'); 51 | 52 | if (empty($json['data'])) { 53 | return []; 54 | } 55 | 56 | return $this->parseEmoteList($json['data']); 57 | } 58 | 59 | /** 60 | * @param int $setId 61 | * @return array 62 | * @throws TwitchEmoteSetIdException 63 | * @throws \GuzzleHttp\Exception\GuzzleException 64 | * @throws \Redbeed\OpenOverlay\Exceptions\AppTokenMissing 65 | */ 66 | public function set(int $setId): array 67 | { 68 | if ($setId > ChatEmotesClient::MAX_SET_ID || $setId < 1) { 69 | throw new TwitchEmoteSetIdException('Set Id minimum: 1 / maximum: ' . ChatEmotesClient::MAX_SET_ID); 70 | } 71 | 72 | $json = $this 73 | ->addAppToken() 74 | ->withOptions([ 75 | RequestOptions::QUERY => [ 76 | 'emote_set_id' => $setId, 77 | ], 78 | ]) 79 | ->request('GET', 'chat/emotes/set'); 80 | 81 | if (empty($json['data'])) { 82 | return []; 83 | } 84 | 85 | return $this->parseEmoteList($json['data']); 86 | } 87 | 88 | public function allSets(): array 89 | { 90 | $emotes = []; 91 | $bulkSize = 10; 92 | 93 | foreach (range(1, (ChatEmotesClient::MAX_SET_ID / $bulkSize)) as $bulk) { 94 | 95 | $to = min(($bulkSize * $bulk), ChatEmotesClient::MAX_SET_ID); 96 | $from = ($bulkSize * $bulk) - 9; 97 | 98 | $json = $this 99 | ->addAppToken() 100 | ->withOptions([ 101 | RequestOptions::QUERY => [ 102 | 'emote_set_id' => range($from, $to), 103 | ], 104 | ]) 105 | ->request('GET', 'chat/emotes/set'); 106 | 107 | if (empty($json['data'])) { 108 | continue; 109 | } 110 | 111 | $emotes += $json['data']; 112 | } 113 | 114 | return $this->parseEmoteList($emotes); 115 | } 116 | 117 | private function parseEmoteList(array $emoteArray): array 118 | { 119 | return collect($emoteArray) 120 | ->map(function ($emoteData) { 121 | return Emote::fromJson($emoteData); 122 | }) 123 | ->toArray(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Service/Twitch/EventSubClient.php: -------------------------------------------------------------------------------- 1 | setOptions([ 30 | RequestOptions::HEADERS => [ 31 | 'Authorization' => 'Bearer ' . $appToken, 32 | ], 33 | ]); 34 | } 35 | 36 | public static function verifySignature( 37 | string $messageSignature, 38 | string $messageId, 39 | string $messageTimestamp, 40 | string $requestBody 41 | ): bool 42 | { 43 | if (empty($messageId) || empty($messageSignature) || empty($messageTimestamp) || empty($requestBody)) { 44 | throw new WebhookTwitchSignatureMissing('Twitch Eventsub Header infomation missing'); 45 | } 46 | 47 | $message = $messageId . $messageTimestamp . $requestBody; 48 | $hash = 'sha256=' . hash_hmac('sha256', $message, config('openoverlay.webhook.twitch.secret')); 49 | 50 | return $hash === $messageSignature; 51 | } 52 | 53 | public function subscribe(string $type, string $webhookCallback, array $condition = [], string $version = '1'): array 54 | { 55 | $secret = config('openoverlay.webhook.twitch.secret'); 56 | 57 | if (empty($webhookCallback)) { 58 | throw new WebhookCallbackMissing('Webhook URL is required'); 59 | } 60 | 61 | if (empty($secret)) { 62 | throw new WebhookSecretMissing('Secret is required'); 63 | } 64 | 65 | return $this 66 | ->withOptions([ 67 | RequestOptions::JSON => [ 68 | 'type' => $type, 69 | 'version' => $version, 70 | 'condition' => $condition, 71 | 'transport' => [ 72 | 'method' => 'webhook', 73 | 'callback' => $webhookCallback . '?' . time(), 74 | 'secret' => $secret, 75 | ], 76 | ], 77 | ]) 78 | ->request('POST', self::BASE_URL); 79 | } 80 | 81 | public function deleteSubByBroadcasterId(string $broadcasterUserId) 82 | { 83 | $subscriptions = $this 84 | ->subscriptions() 85 | ->filter(function ($subscription) use ($broadcasterUserId) { 86 | /** @var EventSubscription $subscription */ 87 | 88 | if (empty($subscription->condition)) { 89 | return false; 90 | } 91 | 92 | if (!empty($subscription->condition['broadcaster_user_id']) && $subscription->condition['broadcaster_user_id'] !== $broadcasterUserId) { 93 | return false; 94 | } 95 | 96 | if (!empty($subscription->condition['to_broadcaster_user_id']) && $subscription->condition['to_broadcaster_user_id'] !== $broadcasterUserId) { 97 | return false; 98 | } 99 | 100 | return true; 101 | }); 102 | 103 | foreach ($subscriptions as $subscription) { 104 | /** @var EventSubscription $subscription */ 105 | $this->deleteSubscription($subscription->id); 106 | } 107 | 108 | $this->subscriptions(); 109 | } 110 | 111 | public function deleteSubscription(string $id) 112 | { 113 | return $this 114 | ->withOptions([ 115 | RequestOptions::QUERY => [ 116 | 'id' => $id, 117 | ], 118 | ]) 119 | ->request('DELETE', self::BASE_URL); 120 | } 121 | 122 | public function subscriptions(): Collection 123 | { 124 | $subData = $this->request('GET', self::BASE_URL); 125 | 126 | return collect($subData['data'])->map(function ($twitchData) { 127 | return EventSubscription::createFromTwitch($twitchData); 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenOverlay 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Total Downloads][ico-downloads]][link-downloads] 5 | 6 | OpenOverlay is a self-hosted service for your web-based twitch overlays and bot. 7 | This Laravel package helps you to receive all twitch events actions while you streaming and show them on your overlay. 8 | Also, you can develop your own bot with simple and advanced commands. 9 | 10 | If you want to start from scratch with your overlay we have an example project. 11 | Standalone-Version: _[redbeed/OpenOverlay-Standalone][link-standalone]_ 12 | 13 | ## Installation 14 | 15 | Via Composer 16 | 17 | ``` bash 18 | $ composer require redbeed/openoverlay 19 | ``` 20 | 21 | ### Configuring 22 | 23 | Add laravel config for OpenOverlay 24 | 25 | ``` bash 26 | php artisan vendor:publish --provider="Redbeed\OpenOverlay\OpenOverlayServiceProvider" 27 | ``` 28 | 29 | Migrate User Connections & Twitch Event table 30 | 31 | ``` bash 32 | php artisan migrate 33 | ``` 34 | 35 | Add configuration to `config/services.php` 36 | 37 | ```php 38 | 'twitch' => [ 39 | 'client_id' => env('TWITCH_CLIENT_ID'), 40 | 'client_secret' => env('TWITCH_CLIENT_SECRET'), 41 | 'redirect' => env('TWITCH_REDIRECT_URI') 42 | ], 43 | ``` 44 | _Thanks to [SocialiteProviders/Twitch][link-socialite]_ 45 | 46 | Add ENV Keys 47 | 48 | ``` bash 49 | TWITCH_CLIENT_ID= 50 | TWITCH_CLIENT_SECRET= 51 | 52 | OVERLAY_SECRET= 53 | OVERLAY_TWITCH_APP_TOKEN= 54 | 55 | TWITCH_REDIRECT_URI=${APP_URL}/connection/callback 56 | ``` 57 | 58 | Add `UserOpenOverlay` trait to `User.php` 59 | 60 | ``` php 61 | [ 90 | 'twitch' => [ 91 | 'app_token' => [ 92 | 'regenerate' => true, 93 | ], 94 | ] 95 | ] 96 | ... 97 | ``` 98 | 99 | Set `regenerate` to `true`. 100 | 101 | 2. Open `${APP_URL}/connection/app-token/redirect` with your laravel-app. 102 | 3. Login into your Twitch-Developer account with your Twitch Application. 103 | 4. Copy the App-Token and use it as value for your `OVERLAY_TWITCH_APP_TOKEN` ENV value. 104 | 105 | ### Add Bot 106 | To add a bot you need to link your app with the bot twitch account. 107 | 108 | 1. Open `${APP_URL}/connection/bot/redirect` with your laravel-app. 109 | 2. Login into your Twitch-Bot account with your Twitch Application. 110 | 3. After redirect you need to manually connect your laravel-Account with a bot. 111 | 4. Open Your Database table "bot_connections" and connect your bot with your user. 112 | 5. Restart the Bot Artisan Bot 113 | 114 | 115 | ## Generate Secret 116 | To validate each Twitch call you need to generate a secret for your app. 117 | If you change the `OVERLAY_SECRET` you need to subscribe each event again. 118 | 119 | ``` bash 120 | php artisan overlay:secret 121 | ``` 122 | 123 | ## Send Fake Events 124 | 125 | You can send "Fake" Events while developing or testing an overlay. 126 | ``` bash 127 | php artisan {TwitchUserId} {EventType} 128 | php artisan 1337 channel.follow 129 | ``` 130 | 131 | ## Change log 132 | 133 | Please see the [changelog](changelog.md) for more information on what has changed recently. 134 | 135 | ## Testing 136 | 137 | ``` bash 138 | $ composer test 139 | ``` 140 | 141 | ## Contributing 142 | 143 | Please see [contributing.md](contributing.md) for details and a todolist. 144 | 145 | ## Security 146 | 147 | If you discover any security related issues, please email author email instead of using the issue tracker. 148 | 149 | ## Credits 150 | 151 | - [redbeed][link-author] 152 | - [Chris Woelk][link-author-chris] 153 | - [All Contributors][link-contributors] 154 | 155 | ## License 156 | 157 | license. Please see the [license file](license.md) for more information. 158 | 159 | [ico-version]: https://img.shields.io/packagist/v/redbeed/openoverlay.svg?style=flat-square 160 | [ico-downloads]: https://img.shields.io/packagist/dt/redbeed/openoverlay.svg?style=flat-square 161 | 162 | [link-packagist]: https://packagist.org/packages/redbeed/openoverlay 163 | [link-downloads]: https://packagist.org/packages/redbeed/openoverlay 164 | [link-travis]: https://travis-ci.org/redbeed/openoverlay 165 | [link-socialite]: https://github.com/SocialiteProviders/Twitch 166 | [link-styleci]: https://styleci.io/repos/12345678 167 | [link-author]: https://github.com/redbeed 168 | [link-author-chris]: https://github.com/chris-redbeed 169 | [link-contributors]: ../../contributors 170 | [link-standalone]: https://github.com/redbeed/OpenOverlay-Standalone 171 | -------------------------------------------------------------------------------- /src/Console/Commands/EventSubDeleteCommand.php: -------------------------------------------------------------------------------- 1 | option('all'); 45 | $type = $this->option('type'); 46 | $status = $this->option('status'); 47 | $subId = $this->option('id'); 48 | $condition = $this->option('condition'); 49 | 50 | if ($all === true) { 51 | return true; 52 | } 53 | 54 | if (empty($type) === false || empty($subId) === false || empty($condition) === false || empty($status) === false) { 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | 61 | public function handle(): void 62 | { 63 | if ($this->validateOptions() === false) { 64 | $this->error('Options not valid'); 65 | 66 | return; 67 | } 68 | 69 | $eventSubClient = EventSubClient::http(); 70 | $subscriptions = $eventSubClient->subscriptions(); 71 | 72 | if ($this->option('all') === false) { 73 | $subscriptions = $this->findByStatus($subscriptions); 74 | $subscriptions = $this->findByType($subscriptions); 75 | $subscriptions = $this->findById($subscriptions); 76 | $subscriptions = $this->findByCondition($subscriptions); 77 | } 78 | 79 | $subscriptionsCount = $subscriptions->count(); 80 | 81 | if ($subscriptionsCount === 0) { 82 | $this->error('No matching subscription founded'); 83 | 84 | return; 85 | } 86 | 87 | if ($this->option('list')) { 88 | $this->subscriptionsTable($subscriptions); 89 | } 90 | 91 | $this->info($subscriptionsCount . ' subscriptions matching your options'); 92 | 93 | if ($this->confirm('Do you wish to delete them?')) { 94 | 95 | $deleteProgress = $this->output->createProgressBar($subscriptionsCount); 96 | $deleteProgress->start(); 97 | 98 | $deleted = 0; 99 | 100 | foreach ($subscriptions as $subscription) { 101 | try { 102 | 103 | $eventSubClient->deleteSubscription($subscription['id']); 104 | $deleted++; 105 | 106 | } catch (RequestException $exception) { 107 | $this->error($subscription['id'] . ' could not deleted'); 108 | } 109 | 110 | $deleteProgress->advance(); 111 | } 112 | 113 | $deleteProgress->finish(); 114 | $this->newLine(2); 115 | 116 | $this->info('Total EventSub deleted: ' . $deleted . '/' . $subscriptionsCount); 117 | } 118 | } 119 | 120 | private function findByCondition(Collection $subscriptions): Collection 121 | { 122 | $condition = $this->option('condition'); 123 | 124 | if (empty($condition)) { 125 | return $subscriptions; 126 | } 127 | 128 | // clean up 129 | $condition = json_encode(json_decode($condition, true)); 130 | 131 | return $subscriptions->filter(function ($subscription) use ($condition) { 132 | /** @var EventSubscription $subscription */ 133 | return $subscription->condition === $condition; 134 | }); 135 | } 136 | 137 | private function findById(Collection $subscriptions): Collection 138 | { 139 | $subId = $this->option('id'); 140 | 141 | if (empty($subId)) { 142 | return $subscriptions; 143 | } 144 | 145 | return $subscriptions->filter(function ($subscription) use ($subId) { 146 | /** @var EventSubscription $subscription */ 147 | return $subscription->id === $subId; 148 | }); 149 | } 150 | 151 | private function findByType(Collection $subscriptions): Collection 152 | { 153 | $type = $this->option('type'); 154 | 155 | if (empty($type)) { 156 | return $subscriptions; 157 | } 158 | 159 | return $subscriptions->filter(function ($subscription) use ($type) { 160 | /** @var EventSubscription $subscription */ 161 | return $subscription->type === $type; 162 | }); 163 | } 164 | 165 | private function findByStatus(Collection $subscriptions): Collection 166 | { 167 | $status = $this->option('status'); 168 | 169 | if (empty($status)) { 170 | return $subscriptions; 171 | } 172 | 173 | return $subscriptions->filter(function ($subscription) use ($status) { 174 | /** @var EventSubscription $subscription */ 175 | return $subscription->status === $status; 176 | }); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Listeners/Twitch/Refresher/Refresher.php: -------------------------------------------------------------------------------- 1 | service !== 'twitch') { 33 | throw new WrongConnectionTypeException('Twitch connection needed'); 34 | } 35 | 36 | $userClient = new UsersClient(); 37 | $followerList = $userClient 38 | ->withAppToken($twitchConnection->service_token) 39 | ->allFollowers($twitchConnection->service_user_id); 40 | 41 | $followerIds = []; 42 | foreach ($followerList['data'] as $followerData) { 43 | $followerIds[] = $followerData['from_id']; 44 | 45 | $followerModal = UserFollowers::withTrashed() 46 | ->where('twitch_user_id', $twitchConnection->service_user_id) 47 | ->where('follower_user_id', $followerData['from_id']) 48 | ->first(); 49 | 50 | if ($followerModal === null) { 51 | UserFollowers::create([ 52 | 'twitch_user_id' => $twitchConnection->service_user_id, 53 | 'follower_user_id' => $followerData['from_id'], 54 | 'follower_username' => $followerData['from_name'], 55 | 'followed_at' => Carbon::parse($followerData['followed_at']), 56 | 'deleted_at' => null, 57 | ]); 58 | 59 | continue; 60 | } 61 | 62 | $followerModal->follower_username = $followerData['from_name']; 63 | $followerModal->followed_at = Carbon::parse($followerData['followed_at']); 64 | 65 | if ($followerModal->trashed()) { 66 | $followerModal->restore(); 67 | } 68 | 69 | $followerModal->save(); 70 | } 71 | 72 | UserFollowers::whereNotIn('follower_user_id', $followerIds) 73 | ->where('twitch_user_id', $twitchConnection->service_user_id) 74 | ->delete(); 75 | } 76 | 77 | /** 78 | * @throws WrongConnectionTypeException 79 | */ 80 | protected function refreshSubscriber(Connection $twitchConnection) 81 | { 82 | if ($twitchConnection->service !== 'twitch') { 83 | throw new WrongConnectionTypeException('Twitch connection needed'); 84 | } 85 | 86 | $twitchUser = $this->twitchUser($twitchConnection->service_user_id); 87 | 88 | if (empty($twitchUser['broadcaster_type'])) { 89 | return; 90 | } 91 | 92 | $subscriptionsClient = new SubscriptionsClient(); 93 | $subscriberList = $subscriptionsClient 94 | ->withAppToken($twitchConnection->service_token) 95 | ->all($twitchConnection->service_user_id); 96 | 97 | $subscriberIds = []; 98 | foreach ($subscriberList['data'] as $subscriberData) { 99 | $subscriberIds[] = $subscriberData['user_id']; 100 | 101 | $subscriberModal = UserSubscriber::withTrashed() 102 | ->where('twitch_user_id', $twitchConnection->service_user_id) 103 | ->where('subscriber_user_id', $subscriberData['user_id']) 104 | ->first(); 105 | 106 | if ($subscriberModal === null) { 107 | UserSubscriber::create([ 108 | 'twitch_user_id' => $twitchConnection->service_user_id, 109 | 110 | 'subscriber_user_id' => $subscriberData['user_id'], 111 | 'subscriber_username' => $subscriberData['user_name'], 112 | 'subscriber_login_name' => $subscriberData['user_login'], 113 | 114 | 'tier' => $subscriberData['tier'], 115 | 'tier_name' => $subscriberData['plan_name'], 116 | 117 | 'is_gift' => $subscriberData['is_gift'], 118 | 'gifter_user_id' => $subscriberData['gifter_id'], 119 | 'gifter_username' => $subscriberData['gifter_name'], 120 | 'gifter_login_name' => $subscriberData['gifter_login'], 121 | ]); 122 | 123 | continue; 124 | } 125 | 126 | $subscriberModal->subscriber_username = $subscriberData['user_name']; 127 | $subscriberModal->subscriber_login_name = $subscriberData['user_login']; 128 | 129 | $subscriberModal->tier = $subscriberData['tier']; 130 | $subscriberModal->tier_name = $subscriberData['plan_name']; 131 | 132 | $subscriberModal->is_gift = $subscriberData['is_gift']; 133 | $subscriberModal->gifter_user_id = $subscriberData['gifter_id']; 134 | $subscriberModal->gifter_username = $subscriberData['gifter_name']; 135 | $subscriberModal->gifter_login_name = $subscriberData['gifter_login']; 136 | 137 | if ($subscriberModal->trashed()) { 138 | $subscriberModal->restore(); 139 | } 140 | 141 | $subscriberModal->save(); 142 | } 143 | 144 | UserSubscriber::whereNotIn('subscriber_user_id', $subscriberIds) 145 | ->where('twitch_user_id', $twitchConnection->service_user_id) 146 | ->delete(); 147 | } 148 | 149 | private function twitchUser(string $broadcasterId): array 150 | { 151 | $userClient = new UsersClient(); 152 | return head(Arr::get($userClient->byId($broadcasterId), 'data', [])); 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /src/ChatBot/Twitch/ConnectionHandler.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 45 | 46 | $this->connection->on('message', function ($message) use ($connection) { 47 | $this->basicMessageHandler($message); 48 | }); 49 | } 50 | 51 | public static function withPrivateMessageHandler(WebSocket $connection): ConnectionHandler 52 | { 53 | $connection = new self($connection); 54 | 55 | $connection->connection->on('message', function ($message) use ($connection) { 56 | $connection->privateMessageHandler($message); 57 | }); 58 | 59 | return $connection; 60 | } 61 | 62 | public function privateMessageHandler(string $message): void 63 | { 64 | // if is chat message 65 | if (strpos($message, 'PRIVMSG') !== false) { 66 | $this->chatMessageReceived($message); 67 | } 68 | } 69 | 70 | public function basicMessageHandler(string $message): void 71 | { 72 | // ignore for basic handler 73 | if (strpos($message, 'PRIVMSG') !== false) { 74 | return; 75 | } 76 | 77 | // get join message 78 | if (strpos($message, 'NOTICE * :Login authentication failed') !== false) { 79 | $this->write("LOGIN | " . $message); 80 | event(new BotTokenExpires($this->bot)); 81 | 82 | $this->connection->close(); 83 | return; 84 | } 85 | 86 | // get join message 87 | if (strpos($message, 'PING') !== false) { 88 | $this->pingReceived($message); 89 | 90 | return; 91 | } 92 | 93 | // get join message 94 | if (strpos($message, 'JOIN') !== false) { 95 | $this->joinMessageReceived($message); 96 | 97 | return; 98 | } 99 | 100 | $this->write("UNKOWN | " . $message . PHP_EOL, ''); 101 | } 102 | 103 | public function pingReceived(string $message): void 104 | { 105 | $this->send('PONG :tmi.twitch.tv'); 106 | $this->write("PING PONG done"); 107 | } 108 | 109 | public function joinMessageReceived(string $message): void 110 | { 111 | try { 112 | preg_match("/:(.*)\!.*#(.*)/", $message, $matches); 113 | 114 | $this->write("BOT (" . $matches[1] . ") joined " . $matches[2]); 115 | 116 | $channelName = trim(strtolower($matches[2])); 117 | 118 | $this->joinedChannel[] = $channelName; 119 | $this->runChannelQueue($channelName); 120 | 121 | $this->afterJoinCallBacks($channelName); 122 | 123 | } catch (\Exception $exception) { 124 | $this->write($exception->getMessage() . ' ' . $exception->getLine() . PHP_EOL, 'ERROR'); 125 | } 126 | } 127 | 128 | private function afterJoinCallBacks(string $channelName) 129 | { 130 | $channelName = strtolower($channelName); 131 | 132 | if (isset($this->joinedCallBack[$channelName])) { 133 | 134 | $this->write('CALL CALLBACK FOR ' . $channelName); 135 | $this->joinedCallBack[$channelName](); 136 | 137 | } 138 | } 139 | 140 | public function addJoinedCallBack(string $channelName, callable $callback): void 141 | { 142 | $channelName = strtolower($channelName); 143 | 144 | $this->joinedCallBack[$channelName] = $callback; 145 | $this->write('Callback added for ' . $channelName); 146 | 147 | // channel already joined 148 | if (in_array($channelName, $this->joinedChannel)) { 149 | $this->afterJoinCallBacks($channelName); 150 | } 151 | } 152 | 153 | public function chatMessageReceived(string $message): void 154 | { 155 | $model = ChatMessage::parseIRCMessage($message); 156 | 157 | if ($model === null) { 158 | return; 159 | } 160 | 161 | $model->possibleEmotes = $this->emoteSets[$model->channel] ?? []; 162 | 163 | $this->write($model->channel . ' | ' . $model->username . ': ' . $model->message, 'Twitch'); 164 | 165 | try { 166 | // Check commands 167 | foreach ($this->customCommands as $commandHandler) { 168 | $commandHandler->handle($model); 169 | } 170 | } catch (\Exception $exception) { 171 | $this->write($exception->getMessage(), 'ERROR'); 172 | $this->write($exception->getFile() . ' #' . $exception->getLine(), 'ERROR'); 173 | } 174 | 175 | $this->write($model->channel . ' | ' . $model->username . ': ' . $model->message . ' HANDLED'); 176 | 177 | try { 178 | event(new ChatMessageReceived($model)); 179 | } catch (\Exception $exception) { 180 | $this->write(" -> EVENT ERROR: " . $exception->getMessage(), 'ERROR'); 181 | } 182 | } 183 | 184 | public function auth(BotConnection $bot) 185 | { 186 | $this->bot = $bot; 187 | 188 | $this->send('PASS oauth:' . $this->bot->service_token); 189 | $this->send('NICK ' . strtolower($this->bot->bot_username)); 190 | } 191 | 192 | public function send(string $message): void 193 | { 194 | $this->connection->send($message); 195 | } 196 | 197 | 198 | public function joinChannel(Connection $channel): void 199 | { 200 | $channelName = strtolower($channel->service_username); 201 | 202 | $this->channelQueue[$channelName] = []; 203 | $this->loadEmotes($channel); 204 | 205 | $this->send('JOIN #' . strtolower($channelName)); 206 | $this->write('JOIN #' . strtolower($channelName)); 207 | } 208 | 209 | private function loadEmotes(Connection $channel) 210 | { 211 | $emoteClient = new ChatEmotesClient(); 212 | $channelName = strtolower($channel->service_username); 213 | 214 | $this->emoteSets[$channelName] = collect($emoteClient->get($channel->service_user_id)) 215 | ->merge($emoteClient->global()) 216 | ->merge($emoteClient->allSets()) 217 | ->toArray(); 218 | } 219 | 220 | private function runChannelQueue(string $channelName): void 221 | { 222 | $channelName = trim(strtolower($channelName)); 223 | 224 | if (!empty($this->channelQueue[$channelName])) { 225 | foreach ($this->channelQueue[$channelName] as $item) { 226 | $this->send($item); 227 | } 228 | } 229 | 230 | $this->channelQueue[$channelName] = []; 231 | } 232 | 233 | public function sendChatMessage(string $channelName, string $message): void 234 | { 235 | $lowerChannelName = strtolower($channelName); 236 | $message = 'PRIVMSG #' . $lowerChannelName . ' :' . $message . PHP_EOL; 237 | 238 | // send message after channel joined 239 | if (!in_array($lowerChannelName, $this->joinedChannel)) { 240 | $this->channelQueue[$lowerChannelName][] = $message; 241 | 242 | return; 243 | } 244 | 245 | $this->send($message); 246 | $this->write($message); 247 | } 248 | 249 | public function initCustomCommands(): void 250 | { 251 | /** @var BotCommand[] $commandClasses */ 252 | $commandClasses = config('openoverlay.bot.commands.advanced'); 253 | 254 | // add simple command handler 255 | $commandClasses[] = SimpleBotCommands::class; 256 | 257 | foreach ($commandClasses as $commandClass) { 258 | $this->customCommands[] = new $commandClass($this); 259 | } 260 | } 261 | 262 | protected function write(string $output, string $title = 'OpenOverlay', $newLine = true) 263 | { 264 | $title = !empty($title) ? '[' . $title . ']' : ''; 265 | echo trim($title . ' ' . $output) . ($newLine ? PHP_EOL : ''); 266 | } 267 | 268 | } 269 | --------------------------------------------------------------------------------