Skip to content

Commit

Permalink
Embed Twitch Live Streaming (forem#2591)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
coreyja authored and benhalpern committed Apr 29, 2019
1 parent 5b03403 commit ec38880
Show file tree
Hide file tree
Showing 24 changed files with 498 additions and 5 deletions.
5 changes: 5 additions & 0 deletions Envfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions app/assets/stylesheets/scaffolds.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ body {
&:active {
outline: 0;
}
}
}


.ptr--ptr{
Expand All @@ -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;
}
}
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/twitch_live_streams_controller.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions app/controllers/twitch_stream_updates_controller.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def update
# 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."
Expand Down
12 changes: 12 additions & 0 deletions app/jobs/streams/twitch_webhook_registration_job.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -400,6 +401,14 @@ def tag_moderator?
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
Expand All @@ -418,6 +427,10 @@ def verify_email
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
Expand Down
1 change: 1 addition & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def permitted_attributes
summary
text_color_hex
twitch_url
twitch_username
username
website_url
export_requested]
Expand Down
35 changes: 35 additions & 0 deletions app/services/streams/twitch_access_token/get.rb
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions app/services/streams/twitch_webhook/register.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/views/twitch_live_streams/no_twitch.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="home">
This user does not use Twitch
</div>
21 changes: 21 additions & 0 deletions app/views/twitch_live_streams/show.html.erb
Original file line number Diff line number Diff line change
@@ -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>
5 changes: 5 additions & 0 deletions app/views/users/_profile.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
15 changes: 14 additions & 1 deletion app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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}" %>"
Expand Down
5 changes: 4 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@
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"
Expand Down
4 changes: 4 additions & 0 deletions config/sample_application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ PUSHER_APP_ID:
PUSHER_KEY:
PUSHER_SECRET:
PUSHER_CLUSTER:

TWITCH_CLIENT_ID:
TWITCH_CLIENT_SECRET:
TWITCH_WEBHOOK_SECRET:
8 changes: 8 additions & 0 deletions db/migrate/20190420000607_add_twitch_columns_to_user.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -809,6 +811,7 @@
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"
Expand Down Expand Up @@ -920,6 +923,7 @@
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"
Expand Down
8 changes: 8 additions & 0 deletions lib/tasks/twitch.rake
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit ec38880

Please sign in to comment.