# coding: utf-8 # frozen_string_literal: true module Stealth class Controller module Replies extend ActiveSupport::Concern included do class_attribute :_preprocessors, default: [:erb] class_attribute :_replies_path, default: [Stealth.root, 'bot', 'replies'] def send_replies(custom_reply: nil, inline: nil) service_reply = load_service_reply( custom_reply: custom_reply, inline: inline ) # Determine if we start at the beginning or somewhere else reply_range = calculate_reply_range offset = reply_range.first @previous_reply = nil service_reply.replies.slice(reply_range).each_with_index do |reply, i| # Updates the lock with the current position of the reply lock_session!( session_slug: current_session.get_session, position: i + offset # Otherwise this won't account for explicit starting points ) begin send_reply(reply: reply) rescue Stealth::Errors::UserOptOut => e user_opt_out_handler(msg: e.message) return rescue Stealth::Errors::InvalidSessionID => e invalid_session_id_handler(msg: e.message) return end @previous_reply = reply end @progressed = :sent_replies ensure release_lock! end private def send_reply(reply:) if !reply.delay? && Stealth.config.auto_insert_delays # if it's the first reply in the service_reply or the previous reply # wasn't a custom delay, then insert a delay if @previous_reply.blank? || !@previous_reply.delay? send_reply(reply: Reply.dynamic_delay) end end # Support randomized replies for text and speech replies. # We select one before handing the reply off to the driver. if reply['text'].is_a?(Array) reply['text'] = reply['text'].sample end handler = reply_handler.new( recipient_id: current_message.sender_id, reply: reply ) translated_reply = handler.send(reply.reply_type) client = service_client.new(reply: translated_reply) client.transmit log_reply(reply) if Stealth.config.transcript_logging # If this was a 'delay' type of reply, we insert the delay if reply.delay? insert_delay(duration: reply['duration']) end end def insert_delay(duration:) begin sleep_duration = if duration == 'dynamic' dyn_duration = dynamic_delay(previous_reply: @previous_reply) Stealth.config.dynamic_delay_muliplier * dyn_duration else Float(duration) end sleep(sleep_duration) rescue ArgumentError, TypeError raise(ArgumentError, 'Invalid duration specified. Duration must be a Numeric') end end def load_service_reply(custom_reply:, inline:) if inline.present? Stealth::ServiceReply.new( recipient_id: current_session_id, yaml_reply: inline, preprocessor: :none, context: nil ) else yaml_reply, preprocessor = action_replies(custom_reply) Stealth::ServiceReply.new( recipient_id: current_session_id, yaml_reply: yaml_reply, preprocessor: preprocessor, context: binding ) end end def service_client begin Kernel.const_get("Stealth::Services::#{current_service.classify}::Client") rescue NameError raise(Stealth::Errors::ServiceNotRecognized, "The service '#{current_service}' was not recognized") end end def reply_handler begin Kernel.const_get("Stealth::Services::#{current_service.classify}::ReplyHandler") rescue NameError raise(Stealth::Errors::ServiceNotRecognized, "The service '#{current_service}' was not recognized") end end def replies_folder current_session.flow_string.underscore.pluralize end def reply_dir [*self._replies_path, replies_folder] end def base_reply_filename "#{current_session.state_string}.yml" end def reply_filenames(custom_reply_filename=nil) reply_filename = if custom_reply_filename.present? custom_reply_filename else base_reply_filename end service_filename = [reply_filename, current_service].join('+') # Service-specific filenames take precedance (returned first) [service_filename, reply_filename] end def find_reply_and_preprocessor(custom_reply) selected_preprocessor = :none if custom_reply.present? dir_and_file = custom_reply.rpartition(File::SEPARATOR) _dir = dir_and_file.first _file = "#{dir_and_file.last}.yml" _replies_dir = [*self._replies_path, _dir] possible_filenames = reply_filenames(_file) reply_file_path = File.join(_replies_dir, _file) service_reply_path = File.join(_replies_dir, reply_filenames(_file).first) else _replies_dir = *reply_dir possible_filenames = reply_filenames reply_file_path = File.join(_replies_dir, base_reply_filename) service_reply_path = File.join(_replies_dir, reply_filenames.first) end # Check if the service_filename exists # If so, we can skip checking for a preprocessor if File.exist?(service_reply_path) return service_reply_path, selected_preprocessor end # Cycles through possible preprocessor and variant combinations # Early returns for performance for preprocessor in self.class._preprocessors do for reply_filename in possible_filenames do selected_filepath = File.join(_replies_dir, [reply_filename, preprocessor.to_s].join('.')) if File.exist?(selected_filepath) reply_file_path = selected_filepath selected_preprocessor = preprocessor return reply_file_path, selected_preprocessor end end end return reply_file_path, selected_preprocessor end def action_replies(custom_reply=nil) reply_path, selected_preprocessor = find_reply_and_preprocessor(custom_reply) begin file_contents = File.read(reply_path) rescue Errno::ENOENT raise(Stealth::Errors::ReplyNotFound, "Could not find reply: '#{reply_path}'") end return file_contents, selected_preprocessor end def user_opt_out_handler(msg:) if self.respond_to?(:handle_opt_out, true) self.send(:handle_opt_out) Stealth::Logger.l( topic: current_service, message: "User #{current_session_id} opted out. [#{msg}]" ) else Stealth::Logger.l( topic: :err, message: "User #{current_session_id} unhandled exception due to opt-out." ) end do_nothing end def invalid_session_id_handler(msg:) if self.respond_to?(:handle_invalid_session_id, true) self.send(:handle_invalid_session_id) Stealth::Logger.l( topic: current_service, message: "User #{current_session_id} has an invalid session_id. [#{msg}]" ) else Stealth::Logger.l( topic: :err, message: "User #{current_session_id} unhandled exception due to an " \ "invalid session_id. [#{msg}]" ) end do_nothing end def calculate_reply_range # if an explicit starting point is specified, use that until the # end of the range, otherwise start at the beginning if @pos.present? (@pos..-1) else (0..-1) end end def log_reply(reply) message = case reply.reply_type when 'text' reply['text'] when 'speech' reply['speech'] when 'ssml' reply['ssml'] when 'delay' '' else "<#{reply.reply_type}>" end Stealth::Logger.l( topic: current_service, message: "User #{current_session_id} -> Sending: #{message}" ) end end # instance methods end end end