middleware("auth")->only([ "subscribe", "addAction" ]); } /** * Subscribe or unsubscribe from a hook event */ public function subscribe(SubscribeWebhook $request) { if ($request->mode === "subscribe") { switch($request->type) { case "stream": return $this->subscribeStreamHook($request); break; } } else if ($request->mode === "unsubscribe") { $hook = Auth::user()->twitchWebhooks()->where("type", $request->type)->first(); if ($hook) { $hook->disabled_at = Carbon::now(); $hook->save(); } return response("Ok"); } } // TODO: implement in a future more than one action per hook public function addAction(CreateDiscordWebhookMessage $request) { $hook = Auth::user()->twitchWebhooks()->where("type", $request->type)->firstOrFail(); // currently hardcoded, allow only one action $discordWebhookMessage = $hook->discordWebhookMessage(); // add if (count($discordWebhookMessage->get()) == 0) { $action = $discordWebhookMessage->create($request->all()); // edit the existing one } else { $action = $discordWebhookMessage->first(); $action->fill($request->all()); $action->save(); } if ($request->hasFile("avatar")) { if (!$request->file("avatar")->isValid()) { // TODO display error message abort(400); return; } $avatarPath = $request->avatar->storeAs("discordWebhookMessages", Auth::user()->twitch_uid . "." . $request->avatar->extension(), "public"); $action->avatar = $avatarPath; $action->save(); } else if ($request->delete_avatar && $action->avatar) { Storage::disk("public")->delete($action->avatar); $action->avatar = null; $action->save(); } return response()->json(["action_id" => $action->id, "avatar" => $action->avatar ? Storage::disk("public")->url($action->avatar) : '']); } /** * Sends a message to a given discord webhook url */ public function testWebhook(TestWebhookAction $request) { $message = new DiscordTextMessage(); $message->setContent($request->content ?? "Test message.") ->setUsername($request->username ?? config("app.name")) ->setAvatar($request->avatar ?? asset("img/twitch-icon.png")); $webhook = new DiscordWebhook($request->discord_webhook_url); $webhook->send($message); return response()->json($webhook); } /** * Create a webhook to listen for stream changes */ private function subscribeStreamHook(Request $request) { $hookCallback = route("hooks.twitch.stream"); $lease = self::MAX_LEASE_SECONDS; if (config("app.env") !== "production") { $lease = 3600; } $topic = self::TOPIC_STREAM_CHANGED . "?user_id=" . Auth::user()->twitch_uid; // TODO: implement secrets, global or specified for each hoock? // $secret = "create a random secret"; $twitchWebhook = Auth::user()->twitchWebhooks()->firstOrNew([ "type" => $request->type ], [ "topic" => $topic, "callback" => $hookCallback, "expires_at" => Carbon::now()->addSeconds($lease) ]); $twitchWebhook->disabled = false; $twitchWebhook->disabled_at = null; if (!$twitchWebhook->id) { $twitchWebhook->save(); $twitchWebhook->callback .= "?hook_id=" . $twitchWebhook->id; $subscription = Twitch::subscribeWebhook($twitchWebhook->callback, $topic, $lease); } else if (config("app.env") !== "production") { $subscription = Twitch::subscribeWebhook($twitchWebhook->callback, $topic, $lease); $twitchWebhook->expires_at = Carbon::now()->addSeconds($lease); } $twitchWebhook->save(); if (isset($subscription) && $subscription->success) { return response("Ok"); } else { $twitchWebhook->disabled = true; $twitchWebhook->save(); return response()->json($subscription, $subscription->status); } } /** * Verifies the webhook (reply to Twitch GET callback after hook creation) */ public function verifySubscription(Request $request) { $twitchHook = TwitchWebhook::findOrFail($request->hook_id); $twitchHook->active = true; $twitchHook->save(); return response($request->input("hub_challenge"), 200)->header("Content-Type", "text/plain"); } // TODO: implement the method // listen to follow events public function follow(Request $request) { if (!$this->verifySecret()) { abort(403); } return "follow"; } /** * Know the stream update type sent by Twitch, returns "start", "update", or "end" */ private function streamUpdateType(TwitchWebhook $twitchWebhook, Request $request) { $data = $request->all()["data"]; Storage::put("stream_hook.json", json_encode($data)); if ($data) { $data = $data[0]; if (empty($twitchWebhook->live_since)) { $eventType = "start"; $twitchWebhook->live_since = Carbon::now(); $twitchWebhook->offline_since = null; $twitchWebhook->save(); } else { $eventType = "update"; } } else { $eventType = "end"; $twitchWebhook->live_since = null; $twitchWebhook->offline_since = Carbon::now(); $twitchWebhook->save(); } return $eventType; } /** * Stream change hook gets here, executes the user configured hook actions */ public function streamUpdate(Request $request) { $twitchWebhook = TwitchWebhook::findOrFail($request->hook_id); if (!$this->verifySecret()) { abort(403); } else if ($twitchWebhook->disabled) { return response("Webhook disabled", 200); } $eventType = $this->streamUpdateType($twitchWebhook, $request); if ( ($eventType === "start" && $twitchWebhook->discordWebhookMessage) || (config("app.env") !== "production" && $twitchWebhook->discordWebhookMessage && $eventType !== "end") ) { $data = $request->all()["data"][0]; // TODO: move this (generation of the message embed) to a separate method $userInfo = Twitch::getUserById(intval($data["user_id"]))->data[0]; $gameInfo = null; if ($data["game_id"]) { $gameInfo = Twitch::getGameById(intval($data["game_id"]))->data[0]; } foreach($twitchWebhook->discordWebhookMessage as $msg) { $this->sendDiscordWebhookMessage($msg, $data, $userInfo, $gameInfo); } } return response("Ok", 200); } /** * Send a message to using the DiscordWebhookMessage preferences and the given data */ private function sendDiscordWebhookMessage(DiscordWebhookMessage $msg, $data, $userInfo, $gameInfo) { $hasEmbed = $msg->has_embed; if ($hasEmbed) { $discordMessage = new DiscordEmbedMessage(); } else { $discordMessage = new DiscordTextMessage(); } $discordMessage->setUsername($msg->username ?? config("app.name")) ->setAvatar($msg->avatar ? Storage::disk("public")->url($msg->avatar) . "?cache=" . Carbon::now()->timestamp : asset("img/twitch-icon.png")) ->setContent($msg->content ?? "¡Hola @everyone! " . $userInfo->display_name . " está ahora en directo https://twitch.tv/" . $userInfo->login . " ! Ven a verle :wink:!"); if ($hasEmbed) { $discordMessage->setColor(hexdec(substr($msg->embed_color ?? "#9147ff", 1))) ->setAuthorName($msg->embed_author_name ? $userInfo->display_name : "") ->setAuthorIcon($msg->embed_author_icon ? $userInfo->profile_image_url : "") ->setAuthorUrl($msg->embed_author_url ? "https://twitch.tv/$userInfo->login" : "") ->setThumbnail($msg->embed_thumbnail ? $userInfo->profile_image_url : "") ->setImage($msg->embed_image ? str_replace(["{width}", "{height}"], [1280, 720], $data["thumbnail_url"]) . "?cache=" . Carbon::now()->timestamp : "") ->setTitle($msg->embed_title ?? $data["title"]) ->setDescription($msg->embed_description ?? "") ->setUrl($msg->embed_url ?? "https://twitch.tv/$userInfo->login"); foreach(json_decode($msg->embed_fields) as $field) { $value; switch($field->value) { case "%game%": $value = $gameInfo ? $gameInfo->name : "─"; break; case "%viewer_count%": $value = $data["viewer_count"]; break; default: $value = $field->value; } $discordMessage->addField($field->name, $value, $field->inline); } } $webhook = new DiscordWebhook($msg->discord_webhook_url); $webhook->send($discordMessage); } // TODO: implement the method public function subscription(Request $request) { if (!$this->verifySecret()) { abort(403); } return "subscription"; } // TODO: verify that the method works ok private function verifySecret(String $secret = null) { if ($secret) { $X_HUB_SIGNATURE = null; if (isset($_SERVER["HTTP_X_HUB_SIGNATURE"])) { $X_HUB_SIGNATURE = $_SERVER["HTTP_X_HUB_SIGNATURE"]; } $data = file_get_contents("php://input"); $hmac = hash_hmac("sha256", $data, $secret); return $hmac == $X_HUB_SIGNATURE; } return true; } }