630 lines
23 KiB
Ruby
630 lines
23 KiB
Ruby
require 'faraday'
|
|
require 'launchy'
|
|
require 'cgi'
|
|
require 'json'
|
|
require 'google/api_client'
|
|
require 'google/api_client/client_secrets'
|
|
require 'google/api_client/auth/installed_app'
|
|
require 'socket'
|
|
|
|
#Google::Apis.logger.level = Logger::DEBUG
|
|
|
|
YOUTUBE_API_SERVICE_NAME = 'youtube'
|
|
YOUTUBE_API_VERSION = 'v3'
|
|
|
|
# Youtube OAuth and API functionality:
|
|
module JamRuby
|
|
class GoogleClient
|
|
attr_accessor :client
|
|
attr_accessor :api
|
|
attr_accessor :request
|
|
attr_accessor :server
|
|
attr_accessor :socket
|
|
attr_accessor :config
|
|
attr_accessor :redirect_uri
|
|
|
|
def initialize()
|
|
self.config = Rails.application.config
|
|
self.redirect_uri='http://localhost:2112/auth/google_login/callback'
|
|
self.client = Google::APIClient.new(
|
|
:application_name => 'JamKazam',
|
|
:application_version => '1.0.0'
|
|
)
|
|
|
|
end
|
|
|
|
def youtube
|
|
@youtube ||= client.discovered_api('youtube', 'v3')
|
|
end
|
|
|
|
def create_authorization(user_auth, scope, autorefresh)
|
|
|
|
authorization = Signet::OAuth2::Client.new(
|
|
:authorization_uri => "https://accounts.google.com/o/oauth2/auth",
|
|
:token_credential_uri => "https://accounts.google.com/o/oauth2/token",
|
|
:client_id => @config.google_client_id,
|
|
:client_secret => @config.google_secret,
|
|
#:redirect_uri => credentials.redirect_uris.first,
|
|
:scope => scope
|
|
)
|
|
authorization.access_token = user_auth.token
|
|
authorization.refresh_token = user_auth.refresh_token
|
|
authorization.expires_at = user_auth.token_expiration
|
|
|
|
if autorefresh && (user_auth.token_expiration < (Time.now - 15)) # add 15 second buffer to this time, because OAUth server does not respond with timestamp, but 'expires_in' which is just offset seconds
|
|
|
|
# XXX: what to do when this fails?
|
|
authorization.refresh!
|
|
user_auth.token = authorization.access_token
|
|
user_auth.token_expiration = authorization.issued_at + authorization.expires_in
|
|
user_auth.save
|
|
end
|
|
authorization
|
|
end
|
|
|
|
def create_client
|
|
Google::APIClient.new(
|
|
:application_name => 'JamKazam',
|
|
:application_version => '1.0.0',
|
|
)
|
|
end
|
|
|
|
# Return a login URL that will show a web page with
|
|
def get_login_url(username=nil)
|
|
puts "GET LOGIN URL"
|
|
uri = "https://accounts.google.com/o/oauth2/auth"
|
|
uri << "?scope=#{CGI.escape('https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube https://gdata.youtube.com email profile ')}" # # https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload
|
|
uri << "&client_id=#{CGI.escape(self.config.google_email)}"
|
|
uri << "&response_type=code"
|
|
uri << "&access_type=online"
|
|
uri << "&prompt=consent"
|
|
uri << "&state=4242"
|
|
uri << "&redirect_uri=#{redirect_uri}"
|
|
if username.present?
|
|
uri << "&login_hint=#{(username)}"
|
|
end
|
|
uri
|
|
end
|
|
|
|
# create youtube broadcast
|
|
def create_broadcast(user, broadcast_options)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
broadcast_data = {
|
|
"snippet" => broadcast_options[:snippet],
|
|
"status" => broadcast_options[:status],
|
|
"contentDetails" => broadcast_options[:contentDetails]
|
|
}
|
|
|
|
begin
|
|
#secrets = Google::APIClient::ClientSecrets.new({"web" => {"access_token" => auth.token, "refresh_token" => auth.refresh_token, "client_id" => @config.google_client_id, "client_secret" => @config.google_secret}})
|
|
|
|
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
|
|
puts "BROADCAST DATA: #{broadcast_data}"
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_broadcasts.insert,
|
|
:parameters => {:part => 'contentDetails,status,snippet'},
|
|
:body_object => broadcast_data)
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "CREATE BROADCAST RESPONSE: #{body}"
|
|
return body
|
|
rescue Google::APIClient::ClientError => e
|
|
# ex:
|
|
=begin
|
|
ex = {
|
|
"error": {
|
|
"errors": [
|
|
{
|
|
"domain": "youtube.liveBroadcast",
|
|
"reason": "liveStreamingNotEnabled",
|
|
"message": "The user is not enabled for live streaming.",
|
|
"extendedHelp": "https://www.youtube.com/features"
|
|
}
|
|
],
|
|
"code": 403,
|
|
"message": "The user is not enabled for live streaming."
|
|
}
|
|
}
|
|
|
|
ex = {
|
|
"error": {
|
|
"errors": [
|
|
{
|
|
"domain": "youtube.liveBroadcast",
|
|
"reason": "insufficientLivePermissions",
|
|
"message": "Request is not authorized",
|
|
"extendedHelp": "https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/insert#auth_required"
|
|
}
|
|
],
|
|
"code": 403,
|
|
"message": "Request is not authorized"
|
|
}
|
|
}
|
|
=end
|
|
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
def bind_broadcast(user, broadcast_id, stream_id)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
begin
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_broadcasts.bind,
|
|
:parameters => {:part => 'id,contentDetails,status,snippet', :id => broadcast_id, :streamId => stream_id })
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "BIND RESPONSE: #{body}"
|
|
return body
|
|
rescue Google::APIClient::ClientError => e
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
def transition_broadcast(user, broadcast_id, broadcastStatus)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
begin
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_broadcasts.transition,
|
|
:parameters => {:part => 'id,contentDetails,status,snippet', :id => broadcast_id, :broadcastStatus => broadcastStatus })
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "TRANSITION RESPONSE: #{body}"
|
|
return body
|
|
rescue Google::APIClient::ClientError => e
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
def get_broadcast(user, broadcast_id)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
begin
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_broadcasts.list,
|
|
:parameters => {:part => 'id,contentDetails,status,snippet', :id => broadcast_id })
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "GET BROADCAST RESPONSE: #{body}"
|
|
if body["items"].length == 0
|
|
nil
|
|
else
|
|
body["items"][0] # returns array of items. meh
|
|
end
|
|
rescue Google::APIClient::ClientError => e
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
def get_livestream(user, stream_id)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
begin
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_streams.list,
|
|
:parameters => {:part => 'id,snippet,cdn,status', :id => stream_id })
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "GET LIVE STREAM RESPONSE: #{body}"
|
|
if body["items"].length == 0
|
|
nil
|
|
else
|
|
body["items"][0] # returns array of items. meh
|
|
end
|
|
rescue Google::APIClient::ClientError => e
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
def create_stream(user, stream_options)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise JamPermissionError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
broadcast_data = {
|
|
"snippet" => stream_options[:snippet],
|
|
"cdn" => stream_options[:cdn],
|
|
"contentDetails" => stream_options[:contentDetails]
|
|
}
|
|
|
|
begin
|
|
my_client = create_client
|
|
|
|
my_client.authorization = create_authorization(auth, 'https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl', true)
|
|
|
|
puts "STREAM DATA: #{broadcast_data}"
|
|
#y = my_client.discovered_api('youtube', 'v3')
|
|
response = my_client.execute!(:api_method => youtube.live_streams.insert,
|
|
:parameters => {:part => 'id,contentDetails,cdn,status,snippet'},
|
|
:body_object => broadcast_data)
|
|
|
|
body = JSON.parse(response.body)
|
|
puts "CREATE STREAM RESPONSE: #{body}"
|
|
return body
|
|
rescue Google::APIClient::ClientError => e
|
|
puts e.result.body
|
|
raise e
|
|
end
|
|
end
|
|
|
|
# create youtube broadcast
|
|
def update_broadcast(user, broadcast_options)
|
|
|
|
end
|
|
|
|
|
|
# Contacts youtube and prepares an upload to youtube. This
|
|
# process is somewhat painful, even in ruby, so we do the preparation
|
|
# and the client does the actual upload using the URL returned:
|
|
|
|
# https://developers.google.com/youtube/v3/docs/videos/insert
|
|
# https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol
|
|
def sign_youtube_upload(user, filename, length)
|
|
raise ArgumentError, "Length is required and should be > 0" if length.to_i.zero?
|
|
|
|
# Something like this:
|
|
# POST /upload/youtube/v3/videos?uploadType=resumable&part=snippet,status,contentDetails HTTP/1.1
|
|
# Host: www.googleapis.com
|
|
# Authorization: Bearer AUTH_TOKEN
|
|
# Content-Length: 278
|
|
# Content-Type: application/json; charset=UTF-8
|
|
# X-Upload-Content-Length: 3000000
|
|
# X-Upload-Content-Type: video/*
|
|
|
|
# {
|
|
# "snippet": {
|
|
# "title": "My video title",
|
|
# "description": "This is a description of my video",
|
|
# "tags": ["cool", "video", "more keywords"],
|
|
# "categoryId": 22
|
|
# },
|
|
# "status": {
|
|
# "privacyStatus": "public",
|
|
# "embeddable": True,
|
|
# "license": "youtube"
|
|
# }
|
|
# }
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise SecurityError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
video_data = {
|
|
"snippet" => {
|
|
"title" => filename,
|
|
"description" => filename,
|
|
"tags" => ["cool", "video", "more keywords"],
|
|
"categoryId" => 1
|
|
},
|
|
"status" => {
|
|
"privacyStatus" => "public",
|
|
"embeddable" => true,
|
|
"license" => "youtube"
|
|
}
|
|
}
|
|
|
|
conn = Faraday.new(:url => "https://www.googleapis.com", :ssl => {:verify => false}) do |faraday|
|
|
faraday.request :url_encoded
|
|
faraday.adapter Faraday.default_adapter
|
|
end
|
|
|
|
video_json=video_data.to_json
|
|
result = conn.post("/upload/youtube/v3/videos?access_token=#{CGI.escape(auth.token)}&uploadType=resumable&part=snippet,status,contentDetails",
|
|
video_json,
|
|
{
|
|
'content-type' => 'application/json;charset=utf-8',
|
|
'x-Upload-Content-Length' => "#{length}",
|
|
'x-upload-content-type' => "video/*"
|
|
}
|
|
)
|
|
|
|
# Response should something look like:
|
|
# HTTP/1.1 200 OK
|
|
# Location: https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&upload_id=xa298sd_f&part=snippet,status,contentDetails
|
|
# Content-Length: 0
|
|
|
|
if (result.nil? || result.status!=200 || result.headers['location'].blank?)
|
|
msg = "Failed signing with status=#{result.status} #{result.inspect}: "
|
|
if result.body.present? && result.body.length > 2
|
|
msg << result.body.inspect # JSON.parse(result.body).inspect
|
|
end
|
|
|
|
# TODO: how to test for this:
|
|
# If reason is "youtubeSignupRequired"
|
|
# If the user's youtube account is unlinked, they'll have to go here.
|
|
# http://m.youtube.com/create_channel. With v3, there is no automated way to do this.
|
|
raise msg
|
|
else
|
|
# This has everything one needs to start the upload to youtube:
|
|
{
|
|
"method" => "PUT",
|
|
"url" => result.headers['location'],
|
|
"Authorization" => "Bearer #{auth.token}",
|
|
"Content-Length" => length,
|
|
"Content-Type" => "video/*"
|
|
}
|
|
end
|
|
end
|
|
|
|
# https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol#Check_Upload_Status
|
|
def youtube_upload_status(user, upload_url, length)
|
|
auth = UserAuthorization.google_auth(user).first
|
|
if auth.nil? || auth.token.nil?
|
|
raise SecurityError, "No current google token found for user #{user}"
|
|
end
|
|
|
|
# PUT UPLOAD_URL HTTP/1.1
|
|
# Authorization: Bearer AUTH_TOKEN
|
|
# Content-Length: 0
|
|
# Content-Range: bytes */CONTENT_LENGTH
|
|
RestClient.put(upload_url, nil, {
|
|
'Authorization' => "Bearer #{auth.token}",
|
|
'Content-Length' => "0",
|
|
'Content-Range' => "bytes */#{length}"
|
|
}) do |response, request, result|
|
|
# Result looks like this:
|
|
# 308 Resume Incomplete
|
|
# Content-Length: 0
|
|
# Range: bytes=0-999999
|
|
case (response.code)
|
|
when 200..207
|
|
result_hash = {
|
|
"offset" => 0,
|
|
"length" => length,
|
|
"status" => response.code
|
|
}
|
|
when 308
|
|
range_str = response.headers['Range']
|
|
if range_str.nil?
|
|
range = 0..length
|
|
else
|
|
range = range_str.split("-")
|
|
end
|
|
result_hash = {
|
|
"offset" => range.first.to_i,
|
|
"length" => range.last.to_i,
|
|
"status" => response.code
|
|
}
|
|
else
|
|
raise "Unexpected status from youtube: [#{response.code}] with headers: #{response.headers.inspect}"
|
|
end
|
|
|
|
result_hash
|
|
end
|
|
end
|
|
|
|
# @return true if file specified by URL uploaded, false otherwise
|
|
def verify_youtube_upload(user, upload_url, length)
|
|
status_hash=youtube_upload_status(user, upload_url, length)
|
|
(status_hash['status']>=200 && status_hash['status']<300)
|
|
end
|
|
|
|
# Set fully_uploaded if the upload can be verified.
|
|
# @return true if verified; false otherwise:
|
|
def complete_upload(recorded_video)
|
|
if (verify_youtube_upload(recorded_video.user, recorded_video.url, recorded_video.length))
|
|
recorded_video.update_attribute(:fully_uploaded, true)
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def verify_recaptcha(recaptcha_response)
|
|
success = false
|
|
if !Rails.application.config.recaptcha_enable
|
|
success = true
|
|
else
|
|
Rails.logger.info "Login with: #{recaptcha_response}"
|
|
RestClient.get("https://www.google.com/recaptcha/api/siteverify",
|
|
params: {
|
|
secret: Rails.application.config.recaptcha_private_key,
|
|
response: recaptcha_response
|
|
}
|
|
) do |response, request, result|
|
|
Rails.logger.info "response: #{response.inspect}"
|
|
case (response.code)
|
|
when 200..207
|
|
json = JSON.parse(response.to_str)
|
|
if json['success']
|
|
success = true
|
|
else
|
|
Rails.logger.info("Error verifying recaptcha: #{json['error-codes'].inspect}")
|
|
end
|
|
else
|
|
Rails.logger.info("Unexpected status from google_recaptcha: [#{response.code}] with headers: #{response.headers.inspect}")
|
|
end #case
|
|
end #do
|
|
end # if
|
|
success
|
|
end
|
|
|
|
#def
|
|
|
|
# This will also sign in and prompt for login as necessary;
|
|
# currently requires the server to be running at localhost:3000
|
|
def signin_flow()
|
|
config = Rails.application.config
|
|
|
|
self.client = Google::APIClient.new(
|
|
:application_name => 'JamKazam',
|
|
:application_version => '1.0.0'
|
|
)
|
|
|
|
raise "SIGNIN FLOW!!"
|
|
flow = Google::APIClient::InstalledAppFlow.new(
|
|
:client_id => config.google_client_id,
|
|
:client_secret => config.google_secret,
|
|
:redirect_uri => redirect_uri,
|
|
:scope => 'email profile https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload'
|
|
)
|
|
|
|
self.client.authorization = flow.authorize
|
|
end
|
|
|
|
# Must manually confirm to obtain refresh token:
|
|
def get_refresh_token
|
|
config = Rails.application.config
|
|
conn = Faraday.new(:url => 'https://accounts.google.com', :ssl => {:verify => false}) do |faraday|
|
|
faraday.request :url_encoded
|
|
faraday.adapter Faraday.default_adapter
|
|
end
|
|
|
|
wait_for_callback do |refresh_token|
|
|
Rails.logger.info("The refresh_token is #{refresh_token}")
|
|
end
|
|
|
|
result = conn.get '/o/oauth2/auth', {
|
|
'scope' => 'email profile https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload',
|
|
'client_id' => config.google_client_id,
|
|
'response_type' => "code",
|
|
'access_type' => "offline",
|
|
'redirect_uri' => redirect_uri
|
|
}
|
|
end
|
|
|
|
def get_access_token(refresh_token)
|
|
refresh_token = "4/g9uZ8S4lq2Bj1J8PPIkgOFKhTKmCHSmRe68iHA75hRg.gj8Nt5bpVYQdPm8kb2vw2M23tnRnkgI"
|
|
|
|
config = Rails.application.config
|
|
conn = Faraday.new(:url => 'https://accounts.google.com', :ssl => {:verify => false}) do |faraday|
|
|
faraday.request :url_encoded
|
|
faraday.adapter Faraday.default_adapter
|
|
end
|
|
|
|
wait_for_callback do |access_token|
|
|
Rails.logger.info("The access_token is #{access_token}")
|
|
end
|
|
|
|
result = conn.post '/o/oauth2/token', nil, {
|
|
'scope' => 'email profile https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload',
|
|
'client_id' => config.google_client_id,
|
|
'client_secret' => config.google_secret,
|
|
'refresh_token' => refresh_token,
|
|
'grant_type' => "refresh_token",
|
|
'redirect_uri' => redirect_uri
|
|
}
|
|
|
|
Rails.logger.info("REsult: #{result.inspect}\n\n")
|
|
end
|
|
|
|
def wait_for_callback(port=3000)
|
|
shutdown()
|
|
self.server = Thread.new {
|
|
Rails.logger.info("STARTING SERVER THREAD...")
|
|
tcp_server = TCPServer.new('localhost', port)
|
|
|
|
self.socket = tcp_server.accept
|
|
if self.socket
|
|
request = self.socket.gets
|
|
Rails.logger.info("REQUEST: #{request}")
|
|
|
|
params=CGI.parse(request)
|
|
code = params['code'].first
|
|
# Whack the end part:
|
|
access_code = code ? code.split(" ").first : ""
|
|
|
|
status = (access_code.present?) ? 'OK' : 'EMPTY'
|
|
Rails.logger.info("access_code is #{status}")
|
|
token=exchange_for_token(access_code)
|
|
yield(token)
|
|
|
|
response = "#{status}\n"
|
|
self.socket.print "HTTP/1.1 200 OK\r\n" +
|
|
"Content-Type: text/plain\r\n" +
|
|
"Content-Length: #{response.bytesize}\r\n" +
|
|
"Connection: close\r\n"
|
|
|
|
self.socket.print "\r\n"
|
|
self.socket.print response
|
|
self.socket.close
|
|
self.socket=nil
|
|
else
|
|
puts "WHY WOULD THIS EVER HAPPEN?"
|
|
raise "WHY WOULD THIS EVER HAPPEN?"
|
|
end
|
|
|
|
}
|
|
end
|
|
|
|
|
|
def exchange_for_token(access_code)
|
|
Rails.logger.info("Exchanging token for code: [#{access_code}]")
|
|
conn = Faraday.new(:url => "https://accounts.google.com", :ssl => {:verify => false}) do |faraday|
|
|
faraday.request :url_encoded
|
|
faraday.adapter Faraday.default_adapter
|
|
end
|
|
|
|
exchange_parms={
|
|
'grant_type' => 'authorization_code',
|
|
'code' => (access_code),
|
|
'client_id' => (config.google_email),
|
|
'client_secret' => (config.google_secret),
|
|
'redirect_uri' => (redirect_uri),
|
|
}
|
|
|
|
result = conn.post('/o/oauth2/token', exchange_parms)
|
|
if result.body.nil? || result.body.blank?
|
|
raise "Result not in correct form: [#{result.body}]"
|
|
end
|
|
|
|
body_hash = JSON.parse(result.body)
|
|
body_hash['access_token']
|
|
end
|
|
|
|
# shutdown
|
|
def shutdown()
|
|
Rails.logger.info("Stopping oauth server...")
|
|
if (self.socket)
|
|
begin
|
|
self.socket.close
|
|
rescue IOError
|
|
# Expected for most cases:
|
|
Rails.logger.info("Socket already closed.")
|
|
end
|
|
self.socket = nil
|
|
end
|
|
end
|
|
end # class
|
|
end # module
|
|
|