Embed Twitch Live Streaming (#2591)

* Get a job created that can create a webhook subscription for a twitch user login

* Remove ngork url

* Refactor add store the access token in the cache

* Get a controller stood up to recieve the webhooks. Now they just need to be processed

* Get User columns added and got webhook controller bones working

* Update the webhook job to use the User

* Add a way for the User to input their Twitch User Name. Plus a linter fix

* Delay webhook registration when profile is updated

* Don't add _ in username

* Use String columns

* Quick fix and add some more requests specs

* Specs for the webhook job

* Get a show page Twitch Live Streams. Just a straight embed of the Twitch Everything Embed UI. Works surprisingly well responsively, and works on all screen sizes

* Fix Gemfile.lock from merge issues

* Add support for expired tokens and add spec

* Add secrets to webhook registration and clean up spec to remove token logic

* Verify webhook secret and spec it

* Add rake task to enqueue webhook registration for all Users. This can be used from Heroku Scheduler

* Update the lease seconds to be for 5 days

* Actually lets do 7 so we can refresh twice a week and try to make sure that we can always miss one

* Hijack the existing Twitch logo instead of making a duplicate one

* Remove comment and replace with log line

* Remove some white space

* Log to Airbrake when webhook errors occur

* Move to passing in an id instead of User object

* Extract logic from Job to Service object

* Capitilize in the view

* Move out of models and into services

* Remove letover stub

* Remove one usage of Faraday

* Use HTTParty for all the HTTP here
This commit is contained in:
Corey Alexander 2019-04-29 18:11:53 -04:00 committed by Ben Halpern
parent 5b0340351b
commit ec388804df
24 changed files with 498 additions and 5 deletions

View file

@ -144,6 +144,11 @@ variable :TWILIO_ACCOUNT_SID, :String, default: "Optional"
variable :TWILIO_VIDEO_API_KEY, :String, default: "Optional"
variable :TWILIO_VIDEO_API_SECRET, :String, default: "Optional"
# For Twitch live stream integration
variable :TWITCH_CLIENT_ID, :String, default: "Optional"
variable :TWITCH_CLIENT_SECRET, :String, default: "Optional"
variable :TWITCH_WEBHOOK_SECRET, :String, default: "Optional"
# Trending tags on home page
variable :TRENDING_TAGS, :String, default: "git,beginners"

View file

@ -18,7 +18,7 @@ body {
&:active {
outline: 0;
}
}
}
.ptr--ptr{
@ -38,7 +38,7 @@ body {
overflow:hidden;
min-height:88vh;
visibility: visible;
&.stories-index,&.notifications-index,&.stories-search,&.podcast_episodes-index,&.reading_list_items-index,.tags-index{
&.stories-index,&.notifications-index,&.stories-search,&.podcast_episodes-index,&.reading_list_items-index,.tags-index,&.twitch_live_streams-show{
margin-top:68px;
}
}

View file

@ -0,0 +1,12 @@
class TwitchLiveStreamsController < ApplicationController
before_action :set_cache_control_headers
def show
@user = User.find_by!(username: params[:username].tr("@", "").downcase)
if @user.twitch_username.present?
render :show
else
render :no_twitch
end
end
end

View file

@ -0,0 +1,44 @@
class TwitchStreamUpdatesController < ApplicationController
skip_before_action :verify_authenticity_token
def show
if params["hub.mode"] == "denied"
airbrake_logger.error("Twitch Webhook was denied: #{params.permit('hub.mode', 'hub.reason', 'hub.topic').to_json}")
head :no_content
else
render plain: params["hub.challenge"]
end
end
def create
head :no_content
unless secret_verified?
airbrake_logger.warn("Twitch Webhook Recieved for which the webhook could not be verified")
return
end
user = User.find(params[:user_id])
if params[:data].first.present?
user.update!(currently_streaming_on: :twitch)
else
user.update!(currently_streaming_on: nil)
end
end
private
def airbrake_logger
Airbrake::AirbrakeLogger.new(Rails.logger)
end
def secret_verified?
twitch_sha = request.headers["x-hub-signature"]
digest = Digest::SHA256.new
digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"]
digest << request.raw_post
twitch_sha == "sha256=#{digest.hexdigest}"
end
end

View file

@ -24,6 +24,7 @@ class UsersController < ApplicationController
# raise permitted_attributes(@user).to_s
if @user.update(permitted_attributes(@user))
RssReader.new.delay.fetch_user(@user) if @user.feed_url.present?
Streams::TwitchWebhookRegistrationJob.perform_later(@user.id) if @user.twitch_username.present?
notice = "Your profile was successfully updated."
if @user.export_requested?
notice += " The export will be emailed to you shortly."

View file

@ -0,0 +1,12 @@
module Streams
class TwitchWebhookRegistrationJob < ApplicationJob
queue_as :twitch_webhook_registration
def perform(user_id, service = TwitchWebhook::Register)
user = User.find_by(id: user_id)
return if user.blank? || user.twitch_username.blank?
service.call(user)
end
end
end

View file

@ -130,6 +130,7 @@ class User < ApplicationRecord
validates :mentee_description, :mentor_description,
length: { maximum: 1000 }
validates :inbox_type, inclusion: { in: %w[open private] }
validates :currently_streaming_on, inclusion: { in: %w[twitch] }, allow_nil: true
validate :conditionally_validate_summary
validate :validate_mastodon_url
validate :validate_feed_url, if: :feed_url_changed?
@ -145,7 +146,7 @@ class User < ApplicationRecord
before_update :mentorship_status_update
before_validation :set_username
# make sure usernames are not empty, to be able to use the database unique index
before_validation :verify_twitter_username, :verify_github_username, :verify_email
before_validation :verify_twitter_username, :verify_github_username, :verify_email, :verify_twitch_username
before_validation :set_config_input
before_validation :downcase_email
before_validation :check_for_username_change
@ -400,6 +401,14 @@ class User < ApplicationRecord
roles.where(name: "tag_moderator").any?
end
def currently_streaming?
currently_streaming_on.present?
end
def currently_streaming_on_twitch?
currently_streaming_on == "twitch"
end
private
def send_welcome_notification
@ -418,6 +427,10 @@ class User < ApplicationRecord
self.email = nil if email == ""
end
def verify_twitch_username
self.twitch_username = nil if twitch_username == ""
end
def set_username
set_temp_username if username.blank?
self.username = username&.downcase

View file

@ -117,6 +117,7 @@ class UserPolicy < ApplicationPolicy
summary
text_color_hex
twitch_url
twitch_username
username
website_url
export_requested]

View file

@ -0,0 +1,35 @@
module Streams
module TwitchAccessToken
class Get
ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY = :twitch_access_token_with_expiration
def self.call
new.call
end
def call
token, exp = Rails.cache.fetch(ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY)
if token.nil? || Time.zone.now >= exp
token, exp = get_new_token
Rails.cache.write(ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, [token, exp])
end
token
end
private
def get_new_token
resp = HTTParty.post(
"https://id.twitch.tv/oauth2/token",
body: {
client_id: ApplicationConfig["TWITCH_CLIENT_ID"],
client_secret: ApplicationConfig["TWITCH_CLIENT_SECRET"],
grant_type: :client_credentials
},
)
[resp["access_token"], resp["expires_in"].seconds.from_now]
end
end
end
end

View file

@ -0,0 +1,49 @@
module Streams
module TwitchWebhook
class Register
WEBHOOK_LEASE_SECONDS = 7.days.to_i
def initialize(user, access_token_service = TwitchAccessToken::Get)
@user = user
@access_token_service = access_token_service
end
def self.call(*args)
new(*args).call
end
def call
user_resp = HTTParty.get("https://api.twitch.tv/helix/users", query: { login: user.twitch_username }, headers: authentication_request_headers)
twitch_user_id = user_resp["data"].first["id"]
HTTParty.post(
"https://api.twitch.tv/helix/webhooks/hub",
body: webhook_request_body(twitch_user_id),
headers: authentication_request_headers,
)
end
private
attr_reader :user, :access_token_service
def webhook_request_body(twitch_user_id)
{
"hub.callback" => twitch_stream_updates_url_for_user(user),
"hub.mode" => "subscribe",
"hub.lease_seconds" => WEBHOOK_LEASE_SECONDS,
"hub.topic" => "https://api.twitch.tv/helix/streams?user_id=#{twitch_user_id}",
"hub.secret" => ApplicationConfig["TWITCH_WEBHOOK_SECRET"]
}.to_json
end
def authentication_request_headers
{ "Authorization" => "Bearer #{access_token_service.call}" }
end
def twitch_stream_updates_url_for_user(user)
Rails.application.routes.url_helpers.user_twitch_stream_updates_url(user_id: user.id, host: ApplicationConfig["APP_DOMAIN"])
end
end
end
end

View file

@ -0,0 +1,3 @@
<div class="home">
This user does not use Twitch
</div>

View file

@ -0,0 +1,21 @@
<div class="home" id="twitch-embed">
</div>
<script id="twitch-embed-js" src="https://embed.twitch.tv/embed/v1.js"></script>
<script type="text/javascript">
function load_twitch() {
new Twitch.Embed("twitch-embed", {
width: "100%",
height: "600px",
channel: "<%= @user.twitch_username %>"
});
}
var scr = document.createElement('script'),
head = document.head || document.getElementsByTagName('head')[0];
scr.src = 'https://embed.twitch.tv/embed/v1.js';
scr.onload = load_twitch;
scr.async = true;
head.insertBefore(scr, head.firstChild);
</script>

View file

@ -150,6 +150,11 @@
<%= f.label :twitch_url, "Twitch URL" %>
<%= f.url_field :twitch_url %>
</div>
<p><strong>Streaming</strong></p>
<div class="field">
<%= f.label :twitch_username, "Twitch User Name" %>
<%= f.text_field :twitch_username %>
</div>
<div class="field">
<label></label>
<%= f.hidden_field :tab, value: @tab %>

View file

@ -105,7 +105,11 @@
<%= inline_svg("gitlab.svg", class: "icon-img") %>
</a>
<% end %>
<% if @user.twitch_url? %>
<% if @user.twitch_username? %>
<%= link_to twitch_live_stream_path(username: @user.username) do %>
<%= inline_svg("twitch-logo.svg", class: "icon-img", id: "icon-twitch") %>
<% end %>
<% elsif @user.twitch_url? %>
<a href="<%= @user.twitch_url %>" target="_blank" rel="noopener nofollow me">
<%= inline_svg("twitch-logo.svg", class: "icon-img") %>
</a>
@ -139,6 +143,15 @@
</div>
<% end %>
<% if @user.currently_streaming_on_twitch? %>
<style>
#icon-twitch path {
fill: green !important;
stroke: green !important;
}
</style>
<% end %>
<div class="home sub-home" id="index-container"
data-params="<%= params.to_json(only: %i[tag username q]) %>" data-which="<%= @list_of %>"
data-algolia-tag="<%= "user_#{@user.id}" %>"

View file

@ -108,7 +108,10 @@ Rails.application.routes.draw do
resources :article_mutes, only: %i[update]
resources :comments, only: %i[create update destroy]
resources :comment_mutes, only: %i[update]
resources :users, only: [:update]
resources :users, only: [:update] do
resource :twitch_stream_updates, only: %i[show create]
end
resources :twitch_live_streams, only: :show, param: :username
resources :reactions, only: %i[index create]
resources :feedback_messages, only: %i[index create]
get "/reports/:slug", to: "feedback_messages#show"

View file

@ -15,3 +15,7 @@ PUSHER_APP_ID:
PUSHER_KEY:
PUSHER_SECRET:
PUSHER_CLUSTER:
TWITCH_CLIENT_ID:
TWITCH_CLIENT_SECRET:
TWITCH_WEBHOOK_SECRET:

View file

@ -0,0 +1,8 @@
class AddTwitchColumnsToUser < ActiveRecord::Migration[5.2]
def change
change_table :users do |t|
t.string :twitch_username, index: true, unique: true
t.string :currently_streaming_on
end
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
@ -809,6 +811,7 @@ ActiveRecord::Schema.define(version: 2019_04_25_210432) do
t.inet "current_sign_in_ip"
t.string "currently_hacking_on"
t.string "currently_learning"
t.string "currently_streaming_on"
t.boolean "display_sponsors", default: true
t.string "dribbble_url"
t.string "editor_version", default: "v1"
@ -920,6 +923,7 @@ ActiveRecord::Schema.define(version: 2019_04_25_210432) do
t.string "text_only_name"
t.string "top_languages"
t.string "twitch_url"
t.string "twitch_username"
t.datetime "twitter_created_at"
t.integer "twitter_followers_count"
t.integer "twitter_following_count"

8
lib/tasks/twitch.rake Normal file
View file

@ -0,0 +1,8 @@
namespace :twitch do
desc "Register for Webhooks for all User's Registered with Twitch"
task wehbook_register_all: :environment do
User.where.not(twitch_username: nil).find_each do |user|
Streams::TwitchWebhookRegistrationJob.perform_later(user.id)
end
end
end

View file

@ -0,0 +1,33 @@
require "rails_helper"
RSpec.describe Streams::TwitchWebhookRegistrationJob, type: :job do
let(:user) { create(:user, twitch_username: "test-username") }
let(:service) { double }
before do
allow(service).to receive(:call)
end
context "when the user does NOT have a twitch username present" do
let(:user) { create(:user) }
it "noops" do
described_class.perform_now(user.id, service)
expect(service).not_to have_received(:call)
end
end
it "noops when the id passed does not belong to a user" do
described_class.perform_now(987_654_321, service)
expect(service).not_to have_received(:call)
end
it "registers for webhooks" do
described_class.perform_now(user.id, service)
expect(service).to have_received(:call).with(user)
end
end

View file

@ -57,6 +57,8 @@ VCR.configure do |config|
)
end
RSpec::Matchers.define_negated_matcher :not_change, :change
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.fixture_path = "#{::Rails.root}/spec/fixtures"

View file

@ -0,0 +1,127 @@
require "rails_helper"
RSpec.describe "TwitchStramUpdates", type: :request do
let(:user) { create(:user, twitch_username: "my-twtich-username", currently_streaming_on: currently_streaming_on) }
let(:currently_streaming_on) { nil }
describe "GET /users/:user_id/twitch_stream_updates" do
context "when the subscription was successfull" do
let(:challenge) { "FAKE_CHALLENGE" }
let(:twitch_webhook_subscription_params) do
{
"hub.mode" => "subscribe",
"hub.topic" => "SOME_TOPIC_URL",
"hub.lease_seconds" => "864000",
"hub.challenge" => challenge
}
end
it "returns the challenge" do
get "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_subscription_params
expect(response.body).to eq challenge
end
end
context "when the subscription is denied" do
let(:twitch_webhook_subscription_params) do
{
"hub.mode" => "denied",
"hub.topic" => "SOME_TOPIC_URL",
"hub.reason" => "unauthorized"
}
end
it "returns a 204 and logs" do
get "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_subscription_params
expect(response.status).to eq 204
end
end
end
describe "POST /users/:user_id/twitch_stream_updates" do
context "when the user was not streaming and starts streaming" do
let(:currently_streaming_on) { nil }
let(:twitch_webhook_params) do
{
data: [{
id: "0123456789",
user_id: "5678",
user_name: "wjdtkdqhs",
game_id: "21779",
community_ids: [],
type: "live",
title: "Best Stream Ever",
viewer_count: 417,
started_at: "2017-12-01T10:09:45Z",
language: "en",
thumbnail_url: "https://link/to/thumbnail.jpg"
}]
}
end
let(:twitch_webhook_secret_sha) do
digest = Digest::SHA256.new
digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"]
digest << twitch_webhook_params.to_json
"sha256=#{digest.hexdigest}"
end
it "updates the Users twitch streaming status" do
expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }.
to change { user.reload.currently_streaming? }.from(false).to(true).
and change { user.reload.currently_streaming_on_twitch? }.from(false).to(true)
end
end
context "when the webhook secret was NOT verified" do
let(:twitch_webhook_params) do
{
data: [{
id: "0123456789",
user_id: "5678",
user_name: "wjdtkdqhs",
game_id: "21779",
community_ids: [],
type: "live",
title: "Best Stream Ever",
viewer_count: 417,
started_at: "2017-12-01T10:09:45Z",
language: "en",
thumbnail_url: "https://link/to/thumbnail.jpg"
}]
}
end
let(:twitch_webhook_secret_sha) { "sha256=BAD_HASH" }
it "noops" do
expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }.
to not_change { user.reload.currently_streaming? }.from(false).
and not_change { user.reload.currently_streaming_on_twitch? }.from(false)
end
end
end
context "when the user was streaming and stops" do
let(:currently_streaming_on) { :twitch }
let(:twitch_webhook_params) do
{
data: []
}
end
let(:twitch_webhook_secret_sha) do
digest = Digest::SHA256.new
digest << ApplicationConfig["TWITCH_WEBHOOK_SECRET"]
digest << twitch_webhook_params.to_json
"sha256=#{digest.hexdigest}"
end
it "updates the Users twitch streaming status" do
expect { post "/users/#{user.id}/twitch_stream_updates", params: twitch_webhook_params.to_json, headers: { "Content-Type" => "application/json", "X-Hub-Signature" => twitch_webhook_secret_sha } }.
to change { user.reload.currently_streaming? }.from(true).to(false).
and change { user.reload.currently_streaming_on_twitch? }.from(true).to(false)
end
end
end

View file

@ -0,0 +1,50 @@
require "rails_helper"
RSpec.describe Streams::TwitchAccessToken::Get, type: :service do
describe ".access_token" do
let(:expected_twitch_token_body) do
{
client_id: "FAKE_TWITCH_CLIENT_ID",
client_secret: "FAKE_TWITCH_CLIENT_SECRET",
grant_type: "client_credentials"
}
end
let!(:twitch_token_stubbed_route) do
stub_request(:post, "https://id.twitch.tv/oauth2/token").
with(body: expected_twitch_token_body).
and_return(body: { access_token: "FAKE_BRAND_NEW_TWITCH_TOKEN", expires_in: 5_184_000 }.to_json, headers: { "Content-Type" => "application/json" })
end
before do
allow(ApplicationConfig).to receive(:[]).and_call_original
allow(ApplicationConfig).to receive(:[]).with("TWITCH_CLIENT_ID").and_return("FAKE_TWITCH_CLIENT_ID")
allow(ApplicationConfig).to receive(:[]).with("TWITCH_CLIENT_SECRET").and_return("FAKE_TWITCH_CLIENT_SECRET")
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache.lookup_store(:memory_store))
end
context "when there is an unexpired token in the cache" do
it "returns the cached token" do
Rails.cache.write(described_class::ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, ["FAKE_UNEXPIRED_TWITCH_TOKEN", 15.days.from_now])
expect(described_class.call).to eq "FAKE_UNEXPIRED_TWITCH_TOKEN"
expect(twitch_token_stubbed_route).not_to have_been_requested
end
end
context "when there is an expired token in the cache" do
it "requests a new token and caches it" do
Rails.cache.write(described_class::ACCESS_TOKEN_AND_EXPIRATION_CACHE_KEY, ["FAKE_EXPIRED_TWITCH_TOKEN", 15.days.ago])
expect(described_class.call).to eq "FAKE_BRAND_NEW_TWITCH_TOKEN"
expect(twitch_token_stubbed_route).to have_been_requested
end
end
context "when the token is not in the cache" do
it "requests a new token and caches it" do
expect(described_class.call).to eq "FAKE_BRAND_NEW_TWITCH_TOKEN"
expect(twitch_token_stubbed_route).to have_been_requested
end
end
end
end

View file

@ -0,0 +1,40 @@
require "rails_helper"
RSpec.describe Streams::TwitchWebhook::Register, type: :service do
describe "::call" do
let(:twitch_access_token_get) { instance_double(Streams::TwitchAccessToken::Get, call: "FAKE_TWITCH_TOKEN") }
let(:user) { create(:user, twitch_username: "test-username") }
let(:expected_headers) do
{ "Authorization" => "Bearer FAKE_TWITCH_TOKEN" }
end
let(:expected_twitch_webhook_params) do
{
"hub.callback" => "http://#{ApplicationConfig['APP_DOMAIN']}/users/#{user.id}/twitch_stream_updates",
"hub.mode" => "subscribe",
"hub.lease_seconds" => 604_800,
"hub.topic" => "https://api.twitch.tv/helix/streams?user_id=654321",
"hub.secret" => ApplicationConfig["TWITCH_WEBHOOK_SECRET"]
}.to_json
end
let!(:twitch_webhook_registration_stubbed_route) do
stub_request(:post, "https://api.twitch.tv/helix/webhooks/hub").
with(body: expected_twitch_webhook_params, headers: expected_headers).
and_return(status: 204)
end
let(:expected_twitch_user_params) { { login: "test-username" } }
let!(:twitch_user_stubbed_route) do
stub_request(:get, "https://api.twitch.tv/helix/users").
with(query: expected_twitch_user_params, headers: expected_headers).
and_return(body: { data: [{ id: 654_321 }] }.to_json, headers: { "Content-Type" => "application/json" })
end
it "registers for webhooks" do
described_class.call(user, twitch_access_token_get)
expect(twitch_webhook_registration_stubbed_route).to have_been_requested
expect(twitch_user_stubbed_route).to have_been_requested
end
end
end