315 lines
11 KiB
Ruby
315 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2011 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
require 'nokogiri'
|
|
|
|
class BigBlueButtonConference < WebConference
|
|
include ActionDispatch::Routing::PolymorphicRoutes
|
|
include CanvasRails::Application.routes.url_helpers
|
|
after_destroy :end_meeting
|
|
after_destroy :delete_all_recordings
|
|
|
|
user_setting_field :record, {
|
|
name: ->{ t('recording_setting', 'Recording') },
|
|
description: ->{ t('recording_setting_enabled_description', 'Enable recording for this conference') },
|
|
type: :boolean,
|
|
default: false,
|
|
visible: ->{ WebConference.config(class_name: BigBlueButtonConference.to_s)[:recording_enabled] },
|
|
}
|
|
|
|
user_setting_field :scheduled_date, {
|
|
name: ->{ t('Scheduled Date') },
|
|
description: ->{ t('Enable recording for this conference') },
|
|
type: :date,
|
|
default: false,
|
|
visible: false
|
|
}
|
|
|
|
def initiate_conference
|
|
return conference_key if conference_key && !retouch?
|
|
unless self.conference_key
|
|
self.conference_key = "instructure_#{self.feed_code}".gsub(/[^a-zA-Z0-9_]/, "_")
|
|
chars = ('a'..'z').to_a + ('0'..'9').to_a
|
|
# create user/admin passwords for this conference. we may want to show
|
|
# the admin passwords in the ui in case moderators need them for any
|
|
# admin-specific functionality within the BBB ui (or we could provide
|
|
# ui for them to specify the password/key)
|
|
settings[:user_key] = 8.times.map{ chars[chars.size * rand] }.join
|
|
settings[:admin_key] = 8.times.map{ chars[chars.size * rand] }.join until settings[:admin_key] && settings[:admin_key] != settings[:user_key]
|
|
end
|
|
settings[:record] &&= config[:recording_enabled]
|
|
settings[:domain] ||= config[:domain] # save the domain
|
|
current_host = URI(settings[:default_return_url] || "http://www.instructure.com").host
|
|
send_request(:create, {
|
|
:meetingID => conference_key,
|
|
:name => title,
|
|
:voiceBridge => format("%020d", self.global_id),
|
|
:attendeePW => settings[:user_key],
|
|
:moderatorPW => settings[:admin_key],
|
|
:logoutURL => (settings[:default_return_url] || "http://www.instructure.com"),
|
|
:record => settings[:record] ? "true" : "false",
|
|
:welcome => settings[:record] ? t("This conference may be recorded.") : "",
|
|
"meta_canvas-recording-ready-user" => recording_ready_user,
|
|
"meta_canvas-recording-ready-url" => recording_ready_url(current_host)
|
|
}) or return nil
|
|
@conference_active = true
|
|
save
|
|
conference_key
|
|
end
|
|
|
|
def recording_ready_user
|
|
if self.grants_right?(self.user, :create)
|
|
"#{self.user['name']} <#{self.user.email}>"
|
|
end
|
|
end
|
|
|
|
def recording_ready_url(current_host = nil)
|
|
polymorphic_url([:api_v1, context, :conferences, :recording_ready],
|
|
conference_id: self.id,
|
|
protocol: HostUrl.protocol,
|
|
host: HostUrl.context_host(context, current_host))
|
|
end
|
|
|
|
def conference_status
|
|
if (result = send_request(:isMeetingRunning, :meetingID => conference_key)) && result[:running] == 'true'
|
|
:active
|
|
else
|
|
:closed
|
|
end
|
|
end
|
|
|
|
def admin_join_url(user, _return_to = "http://www.instructure.com")
|
|
join_url(user, :admin)
|
|
end
|
|
|
|
def participant_join_url(user, _return_to = "http://www.instructure.com")
|
|
join_url(user)
|
|
end
|
|
|
|
def recordings
|
|
fetch_recordings.map do |recording|
|
|
recording_formats(recording)
|
|
end
|
|
end
|
|
|
|
def recording(recording_id = nil)
|
|
unless recording_id.nil?
|
|
recording = fetch_recordings.find{ |r| r[:recordID]==recording_id }
|
|
recording_formats(recording) if recording
|
|
end
|
|
end
|
|
|
|
def recording_formats(recording)
|
|
recording_formats = recording.fetch(:playback, []).map do |format|
|
|
show_to_students = !!format[:length] || format[:type] == "notes" # either is an actual recording or shared notes
|
|
format.merge(:show_to_students => show_to_students)
|
|
end
|
|
{
|
|
recording_id: recording[:recordID],
|
|
title: recording[:name],
|
|
duration_minutes: filter_duration(recording_formats),
|
|
playback_url: nil,
|
|
playback_formats: recording_formats,
|
|
created_at: recording[:startTime].to_i,
|
|
}
|
|
end
|
|
|
|
def delete_recording(recording_id)
|
|
return { deleted: false } if recording_id.nil?
|
|
response = send_request(:deleteRecordings, recordID: recording_id)
|
|
{ deleted: response.present? && response[:deleted].casecmp('true') == 0 }
|
|
end
|
|
|
|
def delete_all_recordings
|
|
fetch_recordings.map do |recording|
|
|
delete_recording recording[:recordID]
|
|
end
|
|
end
|
|
|
|
def close
|
|
end_meeting
|
|
super
|
|
end
|
|
|
|
attr_writer :loaded_recordings
|
|
# we can use the same API method with multiple meeting ids to load all the recordings up in one go
|
|
# instead of making a bunch of individual calls
|
|
def self.preload_recordings(conferences)
|
|
filtered_conferences = conferences.select{|c| c.conference_key && c.settings[:record]}
|
|
return unless filtered_conferences.any?
|
|
|
|
fallback_conferences, current_conferences = filtered_conferences.partition{|c| c.use_fallback_config?}
|
|
fetch_and_preload_recordings(fallback_conferences, use_fallback_config: true) if fallback_conferences.any?
|
|
fetch_and_preload_recordings(current_conferences, use_fallback_config: false) if current_conferences.any?
|
|
end
|
|
|
|
def self.fetch_and_preload_recordings(conferences, use_fallback_config: false)
|
|
# have a limit so we don't send a ridiculously long URL over
|
|
limit = Setting.get("big_blue_button_preloaded_recordings_limit", "50").to_i
|
|
conferences.each_slice(limit) do |sliced_conferences|
|
|
meeting_ids = sliced_conferences.map(&:conference_key).join(",")
|
|
response = send_request(:getRecordings,
|
|
{:meetingID => meeting_ids},
|
|
use_fallback_config: use_fallback_config)
|
|
result = response[:recordings] if response
|
|
result = [] if result.is_a?(String)
|
|
grouped_result = Array(result).group_by{|r| r[:meetingID]}
|
|
sliced_conferences.each do |c|
|
|
c.loaded_recordings = grouped_result[c.conference_key] || []
|
|
end
|
|
end
|
|
end
|
|
|
|
def use_fallback_config?
|
|
# use the fallback config (if possible) if it wasn't created with the current config
|
|
self.class.config[:use_fallback] &&
|
|
self.settings[:domain] != self.class.config[:domain]
|
|
end
|
|
|
|
private
|
|
|
|
def retouch?
|
|
# If we've queried the room status recently, use that result to determine if
|
|
# we need to recreate it.
|
|
unless @conference_active.nil?
|
|
return !@conference_active
|
|
end
|
|
|
|
# BBB removes chat rooms that have been idle fairly quickly.
|
|
# There's no harm in "creating" a room that already exists; the api will
|
|
# just return the room info. So we'll just go ahead and recreate it
|
|
# to make sure we don't accidentally redirect people to an inactive room.
|
|
true
|
|
end
|
|
|
|
def join_url(user, type = :user)
|
|
generate_request :join,
|
|
:fullName => user.short_name,
|
|
:meetingID => conference_key,
|
|
:password => settings[(type == :user ? :user_key : :admin_key)],
|
|
:userID => user.id
|
|
end
|
|
|
|
def end_meeting
|
|
response = send_request(:end, {
|
|
:meetingID => conference_key,
|
|
:password => settings[(type == :user ? :user_key : :admin_key)],
|
|
})
|
|
response[:ended] if response
|
|
end
|
|
|
|
def fetch_recordings
|
|
@loaded_recordings ||= begin
|
|
if conference_key && settings[:record]
|
|
response = send_request(:getRecordings, {
|
|
:meetingID => conference_key,
|
|
})
|
|
result = response[:recordings] if response
|
|
result = [] if result.is_a?(String)
|
|
Array(result)
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
end
|
|
|
|
def generate_request(*args)
|
|
self.class.generate_request(*args)
|
|
end
|
|
|
|
def self.fallback_config
|
|
Canvas::Plugin.find(:big_blue_button_fallback).settings&.with_indifferent_access
|
|
end
|
|
|
|
def self.generate_request(action, options, use_fallback_config: false)
|
|
config_to_use = (use_fallback_config && fallback_config.presence) || config
|
|
query_string = options.to_query
|
|
query_string << ("&checksum=" + Digest::SHA1.hexdigest(action.to_s + query_string + config_to_use[:secret_dec]))
|
|
"https://#{config_to_use[:domain]}/bigbluebutton/api/#{action}?#{query_string}"
|
|
end
|
|
|
|
def send_request(action, options)
|
|
self.class.send_request(action, options, use_fallback_config: use_fallback_config?)
|
|
end
|
|
|
|
def self.send_request(action, options, use_fallback_config: false)
|
|
url_str = generate_request(action, options, use_fallback_config: use_fallback_config)
|
|
http_response = nil
|
|
Canvas.timeout_protection("big_blue_button") do
|
|
logger.debug "big blue button api call: #{url_str}"
|
|
http_response = CanvasHttp.get(url_str, redirect_limit: 5)
|
|
end
|
|
|
|
case http_response
|
|
when Net::HTTPSuccess
|
|
response = xml_to_hash(http_response.body)
|
|
if response[:returncode] == 'SUCCESS'
|
|
return response
|
|
else
|
|
logger.error "big blue button api error #{response[:message]} (#{response[:messageKey]})"
|
|
end
|
|
else
|
|
logger.error "big blue button http error #{http_response}"
|
|
end
|
|
nil
|
|
rescue
|
|
logger.error "big blue button unhandled exception #{$ERROR_INFO}"
|
|
nil
|
|
end
|
|
|
|
def self.xml_to_hash(xml_string)
|
|
doc = Nokogiri::XML(xml_string)
|
|
# assumes the top level value will be a hash
|
|
xml_to_value(doc.root)
|
|
end
|
|
|
|
def self.xml_to_value(node)
|
|
child_elements = node.element_children
|
|
|
|
# if there are no children at all, then this is an empty node
|
|
if node.children.empty?
|
|
nil
|
|
# If no child_elements, this is probably a text node, so just return its content
|
|
elsif child_elements.empty?
|
|
node.content
|
|
# The BBB API follows the pattern where a plural element (ie <bars>)
|
|
# contains many singular elements (ie <bar>) and nothing else. Detect this
|
|
# and return an array to be assigned to the plural element.
|
|
# It excludes the playback node as all of them may be showing different content.
|
|
elsif node.name.singularize == child_elements.first.name || node.name == "playback"
|
|
child_elements.map { |child| xml_to_value(child) }
|
|
# otherwise, make a hash of the child elements
|
|
else
|
|
child_elements.each_with_object({}) do |child, hash|
|
|
hash[child.name.to_sym] = xml_to_value(child)
|
|
|
|
end
|
|
end
|
|
end
|
|
|
|
def filter_duration(recording_formats)
|
|
# This is a filter to take the duration from any of the playback formats that include a value in length.
|
|
# As not all the formats are the actual recording, identify the first one that has :length <> nil
|
|
recording_formats.each do |recording_format|
|
|
return recording_format[:length].to_i if recording_format[:length].present?
|
|
end
|
|
end
|
|
end
|