343 lines
13 KiB
Ruby
343 lines
13 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/>.
|
|
|
|
module ConversationsHelper
|
|
def process_response(
|
|
conversation:,
|
|
context:,
|
|
current_user:,
|
|
session:,
|
|
recipients:,
|
|
context_code:,
|
|
message_ids:,
|
|
body:,
|
|
attachment_ids:,
|
|
domain_root_account_id:,
|
|
media_comment_id:,
|
|
media_comment_type:,
|
|
user_note:
|
|
)
|
|
if conversation.conversation.replies_locked_for?(current_user, recipients)
|
|
raise ConversationsHelper::RepliesLockedForUser.new(message: I18n.t("Unauthorized, unable to add messages to conversation"), status: :unauthorized, attribute: "workflow_state")
|
|
end
|
|
|
|
if context.is_a?(Course) && context.workflow_state == "completed" && !context.grants_right?(current_user, session, :read_as_admin)
|
|
raise ConversationsHelper::Error.new(message: I18n.t("Course concluded, unable to send messages"), status: :unauthorized, attribute: "workflow_state")
|
|
end
|
|
|
|
if body.blank?
|
|
raise ConversationsHelper::Error.new(message: I18n.t("Unable to create message without a body"), status: :bad_request, attribute: "empty_message")
|
|
end
|
|
|
|
recipients = normalize_recipients(
|
|
recipients:,
|
|
context_code:,
|
|
conversation_id: conversation.conversation_id,
|
|
current_user:
|
|
)
|
|
|
|
if recipients && !conversation.conversation.can_add_participants?(recipients)
|
|
raise ConversationsHelper::Error.new(message: I18n.t("Too many participants for group conversation"), status: :bad_request, attribute: "recipients")
|
|
end
|
|
|
|
invalid_recipients = get_invalid_recipients(context, recipients, current_user)
|
|
unless invalid_recipients.to_a.empty?
|
|
invalid_recipients = invalid_recipients.pluck(1)
|
|
raise ConversationsHelper::Error.new(message: I18n.t("The following recipients have no active enrollment in the course, %{invalid_recipients}, unable to send messages", invalid_recipients:), status: :unauthorized, attribute: "recipients")
|
|
end
|
|
|
|
tags = infer_tags(
|
|
recipients: conversation.conversation.participants.pluck(:id),
|
|
context_code:
|
|
)
|
|
|
|
validate_message_ids(message_ids, conversation, current_user:)
|
|
message_args = build_message_args(
|
|
body:,
|
|
attachment_ids:,
|
|
domain_root_account_id:,
|
|
media_comment_id:,
|
|
media_comment_type:,
|
|
user_note:,
|
|
current_user:
|
|
)
|
|
|
|
if conversation.should_process_immediately?
|
|
message = conversation.process_new_message(message_args, recipients, message_ids, tags)
|
|
{ message:, recipients_count: recipients ? recipients.count : 0, status: :ok }
|
|
else
|
|
conversation.delay(strand: "add_message_#{conversation.global_conversation_id}").process_new_message(message_args, recipients, message_ids, tags)
|
|
# The message is delayed and will be processed later so there is nothing to return
|
|
# right now. If there is no error, success can be assumed.
|
|
{ message: nil, recipients_count: recipients ? recipients.count : 0, status: :accepted }
|
|
end
|
|
rescue ConversationsHelper::InvalidMessageForConversationError
|
|
raise ConversationsHelper::Error.new(message: I18n.t("not for this conversation"), status: :bad_request, attribute: "included_messages")
|
|
rescue ConversationsHelper::InvalidMessageParticipantError
|
|
raise ConversationsHelper::Error.new(message: I18n.t("not a participant"), status: :bad_request, attribute: "included_messages")
|
|
end
|
|
|
|
def contexts_for(audience, context_tags)
|
|
result = { courses: {}, groups: {} }
|
|
return result if audience.empty?
|
|
|
|
context_tags.each do |tag|
|
|
next unless tag =~ /\A(course|group)_(\d+)\z/
|
|
|
|
result[:"#{$1}s"][$2.to_i] = []
|
|
end
|
|
|
|
if audience.size == 1 && include_private_conversation_enrollments
|
|
enrollments = Shard.partition_by_shard(result[:courses].keys) do |course_ids|
|
|
next unless audience.first.associated_shards.include?(Shard.current)
|
|
|
|
Enrollment.where(course_id: course_ids, user_id: audience.first.id, workflow_state: "active").select([:course_id, :type]).to_a
|
|
end
|
|
enrollments.each do |enrollment|
|
|
result[:courses][enrollment.course_id] << enrollment.type
|
|
end
|
|
|
|
memberships = Shard.partition_by_shard(result[:groups].keys) do |group_ids|
|
|
next unless audience.first.associated_shards.include?(Shard.current)
|
|
|
|
GroupMembership.where(group_id: group_ids, user_id: audience.first.id, workflow_state: "accepted").select(:group_id).to_a
|
|
end
|
|
memberships.each do |membership|
|
|
result[:groups][membership.group_id] = ["Member"]
|
|
end
|
|
end
|
|
result
|
|
end
|
|
|
|
def normalize_recipients(recipients: nil, context_code: nil, conversation_id: nil, current_user: @current_user)
|
|
if defined?(params)
|
|
recipients ||= params[:recipients]
|
|
context_code ||= params[:context_code]
|
|
conversation_id ||= params[:from_conversation_id]
|
|
end
|
|
|
|
return unless recipients
|
|
|
|
unless recipients.is_a? Array
|
|
recipients = recipients.split ","
|
|
params[:recipients] = recipients if defined?(params)
|
|
end
|
|
|
|
# unrecognized context codes are ignored
|
|
if AddressBook.valid_context?(context_code)
|
|
context = AddressBook.load_context(context_code)
|
|
raise InvalidContextError if context.nil?
|
|
end
|
|
|
|
users, contexts = AddressBook.partition_recipients(recipients)
|
|
known = current_user.address_book.known_users(
|
|
users,
|
|
context:,
|
|
conversation_id:,
|
|
strict_checks: !Account.site_admin.grants_right?(current_user, session, :send_messages)
|
|
)
|
|
|
|
# include users that were already part of the given conversation
|
|
if conversation_id && conversation_id != ""
|
|
unknown_users = users - known.pluck(:id)
|
|
conversation_participant_ids = Conversation.find(conversation_id).participants.pluck(:id)
|
|
unknown_users = unknown_users.select do |unknown_user|
|
|
conversation_participant_ids.include?(unknown_user)
|
|
end
|
|
known.concat(unknown_users.map { |id| MessageableUser.find(id) })
|
|
end
|
|
|
|
contexts.each { |c| known.concat(current_user.address_book.known_in_context(c)) }
|
|
@recipients = known.uniq(&:id)
|
|
@recipients.reject! { |u| u.id == current_user.id } unless @recipients == [current_user] && recipients.count == 1
|
|
@recipients
|
|
end
|
|
|
|
def get_invalid_recipients(context, recipients, current_user)
|
|
if context.is_a?(Course) && context.available? && !recipients.nil? && (context.user_is_student?(current_user) && !context.user_is_instructor?(current_user) && !context.user_is_admin?(current_user))
|
|
valid_student_recipients = context.current_users.pluck(:id, :name)
|
|
recipients.map { |recipient| [recipient.id, recipient.name] } - valid_student_recipients
|
|
end
|
|
end
|
|
|
|
def all_recipients_are_instructors?(context, recipients)
|
|
if context.is_a?(Course)
|
|
return recipients.inject(true) do |all_recipients_are_instructors, recipient|
|
|
all_recipients_are_instructors && context.user_is_instructor?(recipient)
|
|
end
|
|
end
|
|
false
|
|
end
|
|
|
|
def observer_to_linked_students(recipients)
|
|
observee_ids = @current_user.enrollments.where(type: "ObserverEnrollment").distinct.pluck(:associated_user_id)
|
|
return false if observee_ids.empty?
|
|
|
|
recipients.each do |recipient|
|
|
return false if observee_ids.exclude?(recipient.id)
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def valid_context?(context)
|
|
case context
|
|
when Account then valid_account_context?(context)
|
|
when Course, Group then context.membership_for_user(@current_user) || context.grants_right?(@current_user, session, :send_messages)
|
|
else false
|
|
end
|
|
end
|
|
|
|
def valid_account_context?(account)
|
|
return false unless account.root_account?
|
|
return true if account.grants_right?(@current_user, session, :read_roster)
|
|
|
|
user_sub_accounts = @current_user.associated_accounts.shard(@current_user).where(root_account_id: account).to_a
|
|
user_sub_accounts.any? { |a| a.grants_right?(@current_user, session, :read_roster) }
|
|
end
|
|
|
|
def build_message
|
|
Conversation.build_message(*build_message_args)
|
|
end
|
|
|
|
def build_message_args(
|
|
body: nil,
|
|
attachment_ids: nil,
|
|
forwarded_message_ids: nil,
|
|
domain_root_account_id: nil,
|
|
media_comment_id: nil,
|
|
media_comment_type: nil,
|
|
user_note: nil,
|
|
current_user: @current_user
|
|
)
|
|
if defined?(params)
|
|
body ||= params[:body]
|
|
attachment_ids ||= params[:attachment_ids]
|
|
forwarded_message_ids ||= params[:forwarded_message_ids]
|
|
domain_root_account_id ||= @domain_root_account.id
|
|
media_comment_id ||= params[:media_comment_id]
|
|
media_comment_type ||= params[:media_comment_type]
|
|
user_note = value_to_boolean(params[:user_note]) if user_note.nil?
|
|
end
|
|
[
|
|
current_user,
|
|
body,
|
|
{
|
|
attachment_ids:,
|
|
forwarded_message_ids:,
|
|
root_account_id: domain_root_account_id,
|
|
media_comment: infer_media_comment(media_comment_id, media_comment_type, domain_root_account_id, current_user),
|
|
generate_user_note: user_note
|
|
}
|
|
]
|
|
end
|
|
|
|
def infer_media_comment(media_id, media_type, root_account_id, user)
|
|
if media_id.present? && media_type.present?
|
|
media_comment = MediaObject.by_media_id(media_id).first
|
|
unless media_comment
|
|
media_comment ||= MediaObject.new
|
|
media_comment.media_type = media_type
|
|
media_comment.media_id = media_id
|
|
media_comment.root_account_id = root_account_id
|
|
media_comment.user = user
|
|
end
|
|
media_comment.context = user
|
|
media_comment.save
|
|
media_comment
|
|
end
|
|
end
|
|
|
|
def infer_tags(tags: nil, recipients: nil, context_code: nil)
|
|
tags = defined?(params) ? param_array(:tags) : Array(tags || []).compact
|
|
recipients = defined?(params) ? param_array(:recipients) : Array(recipients || []).compact
|
|
context_code = defined?(params) ? param_array(:context_code) : Array(context_code || []).compact
|
|
|
|
tags = tags.concat(recipients).concat(context_code)
|
|
tags = SimpleTags.normalize_tags(tags)
|
|
tags += tags.grep(/\Agroup_(\d+)\z/) { g = Group.where(id: $1.to_i).first and g.context.asset_string }.compact
|
|
@tags = tags.uniq
|
|
end
|
|
|
|
# look up the param and cast it to an array. treat empty string same as empty
|
|
def param_array(key)
|
|
Array(params[key].presence || []).compact
|
|
end
|
|
|
|
def validate_context(context, recipients)
|
|
recipients_are_instructors = all_recipients_are_instructors?(context, recipients)
|
|
|
|
if context.is_a?(Course) &&
|
|
!recipients_are_instructors &&
|
|
!observer_to_linked_students(recipients) &&
|
|
!context.grants_right?(@current_user, session, :send_messages)
|
|
|
|
raise InvalidContextPermissionsError
|
|
elsif !valid_context?(context)
|
|
raise InvalidContextError
|
|
end
|
|
|
|
if context.is_a?(Course) && (context.workflow_state == "completed" || context.soft_concluded?)
|
|
raise CourseConcludedError
|
|
end
|
|
end
|
|
|
|
def validate_message_ids(message_ids, conversation, current_user: @current_user)
|
|
if message_ids
|
|
# sanity check: are the messages part of this conversation?
|
|
db_ids = ConversationMessage.where(id: message_ids, conversation_id: conversation.conversation_id).pluck(:id)
|
|
raise InvalidMessageForConversationError unless db_ids.count == message_ids.count
|
|
|
|
message_ids = db_ids
|
|
|
|
# sanity check: can the user see the included messages?
|
|
found_count = 0
|
|
Shard.partition_by_shard(message_ids) do |shard_message_ids|
|
|
found_count += ConversationMessageParticipant.where(conversation_message_id: shard_message_ids, user_id: current_user).count
|
|
end
|
|
raise InvalidMessageParticipantError unless found_count == message_ids.count
|
|
end
|
|
end
|
|
|
|
class Error < StandardError
|
|
attr_accessor :message, :status, :attribute
|
|
|
|
def initialize(message:, status:, attribute:)
|
|
super
|
|
@message = message
|
|
@status = status
|
|
@attribute = attribute
|
|
end
|
|
end
|
|
|
|
class RepliesLockedForUser < Error; end
|
|
|
|
class InvalidContextError < StandardError; end
|
|
|
|
class InvalidContextPermissionsError < StandardError; end
|
|
|
|
class CourseConcludedError < StandardError; end
|
|
|
|
class InvalidRecipientsError < StandardError; end
|
|
|
|
class InvalidMessageForConversationError < StandardError; end
|
|
|
|
class InvalidMessageParticipantError < StandardError; end
|
|
end
|