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:
- Deploy the code on a local server running Asterisk.
- Create a file named
codes.txt
containing phone numbers and PIN codes. - Prepare the necessary voice recordings (filenames and messages can be found in the application’s source code).
- 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!