Questionnaire application with Asterisk PBX AGI + Ruby


Several AGI implementations are available for Ruby, with Adhearsion currently being a prominent one. I frequently use Adhearsion for my projects. While it is a powerful tool, its size can sometimes limit flexibility.

Prior to Adhearsion, I utilized AsteriskRuby. This library provides AGI and FastAGI connectivity with Asterisk. When combined with Rastman, it also allows access to the Asterisk Manager Interface (AMI). This combination offers versatile functionality. I appreciate both implementations for their simplicity, which facilitates executing Asterisk commands, receiving AMI events, and troubleshooting, even for those not deeply familiar with Ruby. They offer excellent performance, are free of memory leaks, and provide simple, flexible libraries.

Returning to the main topic: the Questionnaire application. This application, based on the AsteriskRuby gem, has been in production for some time. It was initially developed for a small project, and I have now decided to share it with the VoIP community.

The code was developed for Asterisk 1.6.x/1.8.x and Ruby 1.8.x.

To set it up:

  1. Deploy the code on a local server running Asterisk.
  2. Create a file named codes.txt containing phone numbers and PIN codes.
  3. Prepare the necessary voice recordings (filenames and messages can be found in the application’s source code).
  4. Update the extensions.conf file, directing the relevant extension to the FastAGI.

Application workflow:

  • New callers: The system plays a welcome announcement.
  • Returning callers: A “welcome back” announcement is played (authentication is based on caller ID and PIN).
  • The application prompts the caller to enter a PIN code.
  • Upon successful authentication, the system resumes from the last unanswered question (or starts with the first question for new callers).
  • There is no limit on the number of attempts. Answers and call records are stored in the database.

Below is the source code for the FastAGI implementation in Ruby:

#!/bin/env ruby

require 'rubygems'
require 'mysql'
require 'active_record'
require 'AGIServer'
require 'AGIMenu'
require 'AGISelection'
require 'daemons'

APP_ROOT = File.expand_path(File.dirname(__FILE__))

# Questionnaire / voting is statis and here we have a list of correct answers
CORRECT_ANSWERS = %w(2 3 2 1 3 1 1 1 1 3)

# database credentials
DATABASE = { :adapter => :mysql,
  :encoding => :utf8,
  :database => 'voip',
  :pool => 250,
  :connections => 12,
  :username => 'voip',
  :password => 'PASSWORD',
  #:socket => '/var/run/mysqld/mysqld.pid',
  :host => '127.0.0.1',
  :reconnect => true
}

ActiveRecord::Base.establish_connection DATABASE

logger = Logger.new(STDERR)
logger.level = Logger::DEBUG

ActiveRecord::Base.logger = logger
ActiveRecord::Base.colorize_logging = true

# create AR classes and DB tables
class VoteQuestion < ActiveRecord::Base
  set_primary_key :question_number
end

class Questionnaire < ActiveRecord::Base; end

# create DB tables if not exists (dirty way!)
if ! VoteQuestion.table_exists?
  ActiveRecord::Schema.define do
    create_table :questionnairies do |table|
      table.string :pin_code, :limit => 8, :null => false
    end
  end

  #(1..10).each do |pin_code|
  File.read("#{APP_ROOT}/codes.txt").chomp.split("\n").each do |pin_code|
    Questionnaire.create( :pin_code => "00000000#{pin_code.chomp}"[-8,8] )
  end

  ActiveRecord::Schema.define do
    create_table :phone_numbers do |table|
      table.integer :questionnaire_id, :default => nil
      table.integer :number, :limit => 8, :unsigned => true, :null => false
      table.integer :question_number, :null => false, :default => 0
      table.integer :count_of_calls_made, :null => false, :default => 0
      table.integer :amount_of_asked_questions, :null => false, :default => 0
      table.integer :amount_of_correct_answers, :null => false, :default => 0
      table.integer :total_amount_of_asked_questions, :null => false, :default => 0
      table.integer :total_amount_of_correct_answers, :null => false, :default => 0
      table.boolean :sms_sent, :null => false, :default => false
      table.timestamps
    end
    add_index :phone_numbers, :number
  end

  ActiveRecord::Schema.define do
    create_table :vote_questions, :primary_key => :question_number do |table|
      table.integer :amount_of_answers, :null => false, :default => 3
      table.integer :correct_answer, :null => false
    end
  end

  (1..10).each do |question_no|
    VoteQuestion.create(
      :question_number => question_no,
      :amount_of_answers => 3,
      :correct_answer => CORRECT_ANSWERS[question_no-1].to_i
    )
  end
end

class PhoneNumber < ActiveRecord::Base
  validates_uniqueness_of :number
end

class CallAttempt < ActiveRecord::Base
  belongs_to :phone_number
end

# AGI classes
class Farmacy < AGIRoute

  # this method will be called from asteris dialplan (AGI calling) for every new inbound phone call
  def vote
    begin
      agi.answer

      AGIMenu.sounds_dir = AGISelection.sounds_dir = APP_ROOT + '/'

      # prepare the IVR menu
      hash = {:introduction=>["welcome", "instructions"],
        :conclusion=>"what-is-your-choice",
        :timeout=>17,
        :choices=>
        [{:dtmf=>"*", :audio=>["to-go-back", "press", "digits/star"]},
          {:dtmf=>1, :audio=>["press", "digits/1", "for-option-1"]},
          {:dtmf=>2, :audio=>["press", "digits/2", "for-option-2"]},
          {:dtmf=>"#", :audio=>["or", "press", "digits/pound", "to-repeat"]}]}

      # fix callerid and locate caller
      e164 = agi.channel_params['callerid'].to_i
      number = PhoneNumber.find_by_number(e164)

      # increase calls counter for existiing in DB phone number
      unless number.nil?
        number.count_of_calls_made += 1
        number.save
      end
      # create a new DB record for non-existing in DB phone number
      phone_number = if number.nil?
        pn = PhoneNumber.create :number => e164, :count_of_calls_made => 1

        # say 'welcome' or 'welcome back' & save state of caller
        pn.questionnaire_id = hello_new_caller()
        pn.save
        pn

      # if caller is not assigned to the specific Questionnaire, ...
      elsif number.questionnaire_id.nil?
        # say 'welcome' or 'welcome back' & save state of caller
        number.questionnaire_id = hello_new_caller()
        number.save
        number


      else
        welcome_back number.question_number == 0 ? 'Hello_repeat_nopin' : 'Hello_repeat'
        number
      end

      agi.exec 'Playback silence/1'
      ask_question :phone_number => phone_number
      thanks! :phone_number => phone_number

    rescue => err
      puts ">>>>>> ERROR: ", err.backtrace
    end
  end

  private

  def play_ivr(ivr)
    begin
      menu = AGIMenu.new(ivr)
      result = menu.play(:agi => agi)
      result
    rescue => err
      puts ">>>>>> ERROR: ", err.backtrace
      nil
    end
  end

  def playback(audio, timeout = 0.5)
    play_ivr :timeout => timeout, :introduction => audio,
      :choices => [
        {:dtmf => 1}, {:dtmf => 2}, {:dtmf => 3},
        {:dtmf => 4}, {:dtmf => 5}, {:dtmf => 6},
        {:dtmf => 7}, {:dtmf => 8}, {:dtmf => 9},
        {:dtmf=>'*'}, {:dtmf => 0}, {:dtmf=>'#'} ]
  end

  def press_one_to_confirm(return_value=nil)
    code = 1.upto(3) do |count|
      selection =  playback('Wright', 3).to_s
      break return_value if selection.to_s == '1'
      if count == 3 then
        agi.exec "Busy 12"
        agi.exec "Hangup"
        break nil
      end
    end
  end

  def hello_new_caller
    begin
      result = 1.upto(3) do |count|
        selection = if count == 1 then
          AGISelection.new :audio => 'Hello', :max_digits => 8
        else
          AGISelection.new :audio => 'Wrong', :max_digits => 8
        end
        code = selection.read(:agi => agi)
        questionnaire = Questionnaire.find_by_pin_code(code.to_s)
        break questionnaire unless questionnaire.nil?
      end
      if result.class == Questionnaire
        press_one_to_confirm result.id
      else
        agi.exec "Busy 12"
        agi.exec "Hangup"
        nil
      end
    rescue => err
      puts ">>>>>> ERROR: ", err.backtrace
    end
  end

  def welcome_back(message='Hello_repeat')
    playback message
    press_one_to_confirm
  end

  def ask_question(params={})
    begin
      phone_number = params[:phone_number]
      question_number = phone_number.question_number + 1
      question_data =  VoteQuestion.find_by_question_number(question_number)
      if question_data.nil? then # finalize work - no more questions
        return
      end
      ivr_data = { :introduction => question_number, :choices => [ ], :timeout => 12 }
      (1..(question_data.amount_of_answers)).each do |possible_answer|
        ivr_data[:choices].push({
          :dtmf => possible_answer,
          :audio => "#{question_number}-#{possible_answer}"
        })
      end
      ivr_result = play_ivr ivr_data
      phone_number.question_number = question_number
      phone_number.amount_of_asked_questions += 1
      phone_number.total_amount_of_asked_questions += 1
      if question_data.correct_answer == ivr_result.to_i
        phone_number.amount_of_correct_answers += 1
        phone_number.total_amount_of_correct_answers += 1
      end

      phone_number.save
      ask_question :phone_number => phone_number
    rescue => err
      puts err, err.backtrace
    end
  end

  def thanks!(params={})
    phone_number = params[:phone_number]
    if phone_number.amount_of_correct_answers == VoteQuestion.count then
      playback 'Goodbye'
    else
      phone_number.question_number = 0
      phone_number.amount_of_asked_questions = 0
      phone_number.amount_of_correct_answers = 0
      phone_number.save
      playback 'Goodbye_wrong'
    end
  end
end

trap('INT')   { AGIServer.shutdown }
trap('TERM')   { AGIServer.shutdown }

begin
  config = { :bind_port => 4574,
    :min_workers => 5,
    :max_workers => 12,
    :jobs_per_worker => 2,
    :stats => false,
    :bind_host => '127.0.0.1',
    :logger => logger
  }
  AgiServer = AGIServer.new config
rescue Errno::EADDRINUSE
  error = "Cannot start AGI Server, address already in use."
  logger.fatal(error)
  print "#{error}\n"
  exit
else
  print "#{$$}"
end

AgiServer.start
AgiServer.finish

Good luck!