├── .rspec ├── Gemfile ├── cucumber.yml ├── lib ├── ruby_ami │ ├── version.rb │ ├── core_ext │ │ └── celluloid.rb │ ├── event.rb │ ├── metaprogramming.rb │ ├── error.rb │ ├── async_agi_environment_parser.rb │ ├── agi_result_parser.rb │ ├── response.rb │ ├── stream.rb │ ├── lexer_machine.rl │ ├── action.rb │ ├── client.rb │ └── lexer.rl.rb └── ruby_ami.rb ├── spec ├── ruby_ami │ ├── error_spec.rb │ ├── response_spec.rb │ ├── agi_result_parser_spec.rb │ ├── async_agi_environment_parser_spec.rb │ ├── event_spec.rb │ ├── stream_spec.rb │ ├── action_spec.rb │ └── client_spec.rb ├── spec_helper.rb └── support │ └── mock_server.rb ├── .gitignore ├── .travis.yml ├── Guardfile ├── features ├── support │ ├── env.rb │ ├── introspective_lexer.rb │ ├── ami_fixtures.yml │ └── lexer_helper.rb ├── step_definitions │ └── lexer_steps.rb └── lexer.feature ├── LICENSE.txt ├── ruby_ami.gemspec ├── Rakefile ├── README.md └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --colour 3 | --tty 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | default: --tags ~@wip 2 | wip: --wip --tags @wip 3 | -------------------------------------------------------------------------------- /lib/ruby_ami/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | VERSION = "1.2.5" 4 | end 5 | -------------------------------------------------------------------------------- /lib/ruby_ami/core_ext/celluloid.rb: -------------------------------------------------------------------------------- 1 | module Celluloid::Logger 2 | def self.trace(*args, &block) 3 | debug *args, &block 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/ruby_ami/error_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Error do 6 | pending 7 | end # Error 8 | end # RubyAMI 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | 6 | lib/ruby_ami/lexer.rb 7 | .rvmrc 8 | .yardoc 9 | doc 10 | coverage 11 | spec/reports 12 | features/reports 13 | vendor 14 | *.swp 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.2 4 | - 1.9.3 5 | - jruby-19mode 6 | # - rbx-19mode 7 | # - ruby-head 8 | before_install: 9 | - wget http://ftp.us.debian.org/debian/pool/main/r/ragel/ragel_6.7-1.1_i386.deb 10 | - sudo dpkg -i ragel_6.7-1.1_i386.deb 11 | notifications: 12 | irc: "irc.freenode.org#adhearsion" 13 | -------------------------------------------------------------------------------- /lib/ruby_ami/event.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'ruby_ami/response' 3 | 4 | module RubyAMI 5 | class Event < Response 6 | attr_reader :name 7 | 8 | def initialize(name) 9 | super() 10 | @name = name 11 | end 12 | 13 | def inspect_attributes 14 | [:name] + super 15 | end 16 | end 17 | end # RubyAMI 18 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'shell', :all_on_start => true do 2 | watch("lib/ruby_ami/lexer_machine.rl") { `rake ragel` } 3 | end 4 | 5 | guard 'rspec', :version => 2, :cli => '--format documentation' do 6 | watch(%r{^spec/.+_spec\.rb$}) 7 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 8 | watch('spec/spec_helper.rb') { "spec/" } 9 | end 10 | -------------------------------------------------------------------------------- /lib/ruby_ami/metaprogramming.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class Object 3 | def metaclass 4 | class << self 5 | self 6 | end 7 | end 8 | 9 | def meta_eval(&block) 10 | metaclass.instance_eval &block 11 | end 12 | 13 | def meta_def(name, &block) 14 | meta_eval do 15 | define_method name, &block 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'simplecov-rcov' 3 | class SimpleCov::Formatter::MergedFormatter 4 | def format(result) 5 | SimpleCov::Formatter::HTMLFormatter.new.format(result) 6 | SimpleCov::Formatter::RcovFormatter.new.format(result) 7 | end 8 | end 9 | SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter 10 | SimpleCov.start do 11 | add_filter "/vendor/" 12 | end 13 | 14 | require 'cucumber' 15 | require 'rspec' 16 | require 'ruby_ami' 17 | -------------------------------------------------------------------------------- /lib/ruby_ami.rb: -------------------------------------------------------------------------------- 1 | %w{ 2 | future-resource 3 | logger 4 | girl_friday 5 | countdownlatch 6 | celluloid/io 7 | }.each { |f| require f } 8 | 9 | class Logger 10 | alias :trace :debug 11 | end 12 | 13 | module RubyAMI 14 | def self.new_uuid 15 | SecureRandom.uuid 16 | end 17 | end 18 | 19 | %w{ 20 | core_ext/celluloid 21 | action 22 | agi_result_parser 23 | async_agi_environment_parser 24 | client 25 | error 26 | event 27 | lexer 28 | metaprogramming 29 | response 30 | stream 31 | version 32 | }.each { |f| require "ruby_ami/#{f}" } 33 | -------------------------------------------------------------------------------- /lib/ruby_ami/error.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | class Error < StandardError 4 | attr_accessor :message, :action 5 | 6 | def initialize 7 | @headers = Hash.new 8 | end 9 | 10 | def [](key) 11 | @headers[key] 12 | end 13 | 14 | def []=(key,value) 15 | @headers[key] = value 16 | end 17 | 18 | def action_id 19 | @headers['ActionID'] 20 | end 21 | 22 | def inspect 23 | "#<#{self.class} #{[:message, :headers].map { |c| "#{c}=#{self.__send__(c).inspect rescue nil}" }.compact * ', '}>" 24 | end 25 | end 26 | end # RubyAMI 27 | -------------------------------------------------------------------------------- /features/support/introspective_lexer.rb: -------------------------------------------------------------------------------- 1 | class IntrospectiveManagerStreamLexer < RubyAMI::Lexer 2 | attr_reader :received_messages, :syntax_errors, :ami_errors 3 | 4 | def initialize(*args) 5 | super 6 | @received_messages = [] 7 | @syntax_errors = [] 8 | @ami_errors = [] 9 | end 10 | 11 | def message_received(message = @current_message) 12 | @received_messages << message 13 | end 14 | 15 | def error_received(error_message) 16 | @ami_errors << error_message 17 | end 18 | 19 | def syntax_error_encountered(ignored_chunk) 20 | @syntax_errors << ignored_chunk 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/ruby_ami/async_agi_environment_parser.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'cgi' 3 | 4 | module RubyAMI 5 | class AsyncAGIEnvironmentParser 6 | def initialize(environment_string) 7 | @environment_string = environment_string.dup 8 | end 9 | 10 | def to_hash 11 | to_array.inject({}) do |accumulator, element| 12 | accumulator[element[0].to_sym] = element[1] || '' 13 | accumulator 14 | end 15 | end 16 | 17 | def to_s 18 | @environment_string.dup 19 | end 20 | 21 | private 22 | 23 | def to_array 24 | CGI.unescape(@environment_string).split("\n").map { |p| p.split ': ' } 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /features/support/ami_fixtures.yml: -------------------------------------------------------------------------------- 1 | :login: 2 | :standard: 3 | :client: 4 | Action: Login 5 | Username: :string 6 | Secret: :string 7 | Events: {one_of: ["on", "off"]} 8 | :success: 9 | Response: Success 10 | Message: Authentication accepted 11 | :fail: 12 | Response: Error 13 | Message: Authentication failed 14 | 15 | :errors: 16 | :missing_action: 17 | Response: Error 18 | Message: Missing action in request 19 | 20 | :pong: 21 | :with_action_id: 22 | ActionID: 1287381.1238 23 | Response: Pong 24 | :without_action_id: 25 | Response: Pong 26 | :with_extra_keys: 27 | ActionID: 1287381.1238 28 | Response: Pong 29 | Blah: This is something arbitrary 30 | Blahhh: something else arbitrary -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | begin 3 | require 'simplecov' 4 | require 'simplecov-rcov' 5 | class SimpleCov::Formatter::MergedFormatter 6 | def format(result) 7 | SimpleCov::Formatter::HTMLFormatter.new.format(result) 8 | SimpleCov::Formatter::RcovFormatter.new.format(result) 9 | end 10 | end 11 | SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter 12 | SimpleCov.start do 13 | add_filter "/vendor/" 14 | end 15 | rescue Exception => e 16 | puts "Couldn't load simplecov" 17 | puts e.message 18 | puts e.backtrace.join("\n") 19 | end 20 | 21 | require 'ruby_ami' 22 | require 'countdownlatch' 23 | 24 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f} 25 | 26 | include RubyAMI 27 | 28 | RSpec.configure do |config| 29 | config.mock_with :mocha 30 | config.filter_run :focus => true 31 | config.run_all_when_everything_filtered = true 32 | 33 | config.before :each do 34 | uuid = RubyAMI.new_uuid 35 | RubyAMI.stubs :new_uuid => uuid 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Ben Langfeld, Jay Phillips 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/ruby_ami/agi_result_parser.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | 3 | module RubyAMI 4 | class AGIResultParser 5 | attr_reader :code, :result, :data 6 | 7 | FORMAT = /^(?\d{3}) result=(?-?\d*) ?(?\(?.*\)?)?$/.freeze 8 | DATA_KV_FORMAT = /(?[\w\d]+)=(?[\w\d]*)/.freeze 9 | DATA_CLEANER = /(^\()|(\)$)/.freeze 10 | 11 | def initialize(result_string) 12 | @result_string = result_string.dup 13 | raise ArgumentError, "The result string did not match the required format." unless match 14 | parse 15 | end 16 | 17 | def data_hash 18 | return unless data_kv_match 19 | {data_kv_match[:key] => data_kv_match[:value]} 20 | end 21 | 22 | private 23 | 24 | def unescape 25 | CGI.unescape @result_string 26 | end 27 | 28 | def match 29 | @match ||= unescape.chomp.match(FORMAT) 30 | end 31 | 32 | def parse 33 | @code = match[:code].to_i 34 | @result = match[:result].to_i 35 | @data = match[:data] ? match[:data].gsub(DATA_CLEANER, '').freeze : nil 36 | end 37 | 38 | def data_kv_match 39 | @data_kv_match ||= data.match(DATA_KV_FORMAT) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/mock_server.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | MockServer = Class.new 3 | 4 | class ServerMock 5 | include Celluloid::IO 6 | 7 | def initialize(host, port, mock_target = MockServer.new) 8 | puts "*** Starting echo server on #{host}:#{port}" 9 | @server = TCPServer.new host, port 10 | @mock_target = mock_target 11 | @clients = [] 12 | run! 13 | end 14 | 15 | def finalize 16 | Logger.debug "ServerMock finalizing" 17 | @server.close if @server 18 | @clients.each(&:close) 19 | end 20 | 21 | def run 22 | after(0.5) { terminate } 23 | loop { handle_connection! @server.accept } 24 | end 25 | 26 | def handle_connection(socket) 27 | @clients << socket 28 | _, port, host = socket.peeraddr 29 | puts "*** Received connection from #{host}:#{port}" 30 | loop { receive_data socket.readpartial(4096) } 31 | end 32 | 33 | def receive_data(data) 34 | Logger.debug "ServerMock receiving data: #{data}" 35 | @mock_target.receive_data data, self 36 | end 37 | 38 | def send_data(data) 39 | @clients.each { |client| client.write data.gsub("\n", "\r\n") } 40 | end 41 | end 42 | 43 | def client 44 | @client ||= mock('Client') 45 | end 46 | -------------------------------------------------------------------------------- /lib/ruby_ami/response.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | ## 4 | # This is the object containing a response from Asterisk. 5 | # 6 | # Note: not all responses have an ActionID! 7 | # 8 | class Response 9 | class << self 10 | def from_immediate_response(text) 11 | new.tap do |instance| 12 | instance.text_body = text 13 | end 14 | end 15 | end 16 | 17 | attr_accessor :action, 18 | :text_body # For "Response: Follows" sections 19 | attr_reader :events 20 | 21 | def initialize 22 | @headers = Hash.new 23 | end 24 | 25 | def has_text_body? 26 | !!@text_body 27 | end 28 | 29 | def headers 30 | @headers.clone 31 | end 32 | 33 | def [](arg) 34 | @headers[arg.to_s] 35 | end 36 | 37 | def []=(key,value) 38 | @headers[key.to_s] = value 39 | end 40 | 41 | def action_id 42 | @headers['ActionID'] 43 | end 44 | 45 | def inspect 46 | "#<#{self.class} #{inspect_attributes.map { |c| "#{c}=#{self.__send__(c).inspect rescue nil}" }.compact * ', '}>" 47 | end 48 | 49 | def inspect_attributes 50 | [:headers, :text_body, :events, :action] 51 | end 52 | 53 | def eql?(o, *fields) 54 | o.is_a?(self.class) && (fields + inspect_attributes).all? { |f| self.__send__(f) == o.__send__(f) } 55 | end 56 | alias :== :eql? 57 | end 58 | end # RubyAMI 59 | -------------------------------------------------------------------------------- /spec/ruby_ami/response_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Response do 6 | describe "equality" do 7 | context "with the same headers" do 8 | let :event1 do 9 | Response.new.tap do |e| 10 | e['Channel'] = 'SIP/101-3f3f' 11 | e['Uniqueid'] = '1094154427.10' 12 | e['Cause'] = '0' 13 | end 14 | end 15 | 16 | let :event2 do 17 | Response.new.tap do |e| 18 | e['Channel'] = 'SIP/101-3f3f' 19 | e['Uniqueid'] = '1094154427.10' 20 | e['Cause'] = '0' 21 | end 22 | end 23 | 24 | it "should be equal" do 25 | event1.should be == event2 26 | end 27 | end 28 | 29 | context "with different headers" do 30 | let :event1 do 31 | Response.new.tap do |e| 32 | e['Channel'] = 'SIP/101-3f3f' 33 | e['Uniqueid'] = '1094154427.10' 34 | e['Cause'] = '0' 35 | end 36 | end 37 | 38 | let :event2 do 39 | Response.new.tap do |e| 40 | e['Channel'] = 'SIP/101-3f3f' 41 | e['Uniqueid'] = '1094154427.10' 42 | e['Cause'] = '1' 43 | end 44 | end 45 | 46 | it "should not be equal" do 47 | event1.should_not be == event2 48 | end 49 | end 50 | end 51 | end # Response 52 | end # RubyAMI 53 | -------------------------------------------------------------------------------- /ruby_ami.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "ruby_ami/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "ruby_ami" 7 | s.version = RubyAMI::VERSION 8 | s.authors = ["Ben Langfeld"] 9 | s.email = ["ben@langfeld.me"] 10 | s.homepage = "" 11 | s.summary = %q{Futzing with AMI so you don't have to} 12 | s.description = %q{A Ruby client library for the Asterisk Management Interface build on eventmachine.} 13 | 14 | s.rubyforge_project = "ruby_ami" 15 | 16 | s.files = `git ls-files`.split("\n") << 'lib/ruby_ami/lexer.rb' 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ["lib"] 20 | 21 | s.add_runtime_dependency %q, ["~> 0.12.0"] 22 | s.add_runtime_dependency %q, [">= 0"] 23 | s.add_runtime_dependency %q, [">= 0"] 24 | s.add_runtime_dependency %q, ["~> 1.0"] 25 | 26 | s.add_development_dependency %q, ["~> 1.0"] 27 | s.add_development_dependency %q, ["~> 2.5"] 28 | s.add_development_dependency %q, [">= 0"] 29 | s.add_development_dependency %q, ["~> 1.6"] 30 | s.add_development_dependency %q, ["~> 0.6"] 31 | s.add_development_dependency %q, [">= 0"] 32 | s.add_development_dependency %q, [">= 0"] 33 | s.add_development_dependency %q, [">= 0"] 34 | s.add_development_dependency %q, [">= 0"] 35 | s.add_development_dependency %q 36 | s.add_development_dependency %q 37 | s.add_development_dependency %q 38 | end 39 | -------------------------------------------------------------------------------- /spec/ruby_ami/agi_result_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe AGIResultParser do 6 | subject { described_class.new result_string } 7 | 8 | context 'with something that does not match the valid format' do 9 | let(:result_string) { 'foobar' } 10 | 11 | it 'should raise ArgumentError on creation' do 12 | expect { subject }.to raise_error(ArgumentError, /format/) 13 | end 14 | end 15 | 16 | context 'with a simple result with no data' do 17 | let(:result_string) { "200%20result=123%0A" } 18 | 19 | its(:code) { should == 200 } 20 | its(:result) { should == 123 } 21 | its(:data) { should == '' } 22 | its(:data_hash) { should == nil } 23 | end 24 | 25 | context 'with a simple unescaped result with no data' do 26 | let(:result_string) { "200 result=123" } 27 | 28 | its(:code) { should == 200 } 29 | its(:result) { should == 123 } 30 | its(:data) { should == '' } 31 | its(:data_hash) { should == nil } 32 | end 33 | 34 | context 'with a result and data in parens' do 35 | let(:result_string) { "200%20result=-123%20(timeout)%0A" } 36 | 37 | its(:code) { should == 200 } 38 | its(:result) { should == -123 } 39 | its(:data) { should == 'timeout' } 40 | its(:data_hash) { should == nil } 41 | end 42 | 43 | context 'with a result and key-value data' do 44 | let(:result_string) { "200%20result=123%20foo=bar%0A" } 45 | 46 | its(:code) { should == 200 } 47 | its(:result) { should == 123 } 48 | its(:data) { should == 'foo=bar' } 49 | its(:data_hash) { should == {'foo' => 'bar'} } 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rspec/core' 4 | require 'rspec/core/rake_task' 5 | require 'ci/reporter/rake/rspec' 6 | RSpec::Core::RakeTask.new(:spec) do |spec| 7 | spec.pattern = 'spec/**/*_spec.rb' 8 | spec.rspec_opts = '--color' 9 | end 10 | 11 | require 'cucumber' 12 | require 'cucumber/rake/task' 13 | require 'ci/reporter/rake/cucumber' 14 | Cucumber::Rake::Task.new(:features) do |t| 15 | t.cucumber_opts = %w{--tags ~@jruby} unless defined?(JRUBY_VERSION) 16 | end 17 | 18 | Cucumber::Rake::Task.new(:wip) do |t| 19 | t.cucumber_opts = %w{-p wip} 20 | end 21 | 22 | task :default => [:ragel, :spec, :features] 23 | task :ci => [:ragel, 'ci:setup:rspec', :spec, 'ci:setup:cucumber', :features] 24 | 25 | require 'yard' 26 | YARD::Rake::YardocTask.new 27 | 28 | desc "Check Ragel version" 29 | task :check_ragel_version do 30 | ragel_version_match = `ragel --version`.match /(\d)\.(\d)+/ 31 | abort "Could not get Ragel version! Is it installed? You must have at least version 6.7" unless ragel_version_match 32 | big, small = ragel_version_match.captures.map &:to_i 33 | puts "You're using Ragel v#{ragel_version_match[0]}" 34 | if big < 6 || big == 6 && small < 7 35 | abort "Please upgrade Ragel! v6.7 or later is required" 36 | end 37 | end 38 | 39 | desc "Used to regenerate the AMI source code files. Note: requires Ragel 6.3 or later be installed on your system" 40 | task :ragel => :check_ragel_version do 41 | run_ragel '-n -R' 42 | end 43 | 44 | desc "Generates a GraphVis document showing the Ragel state machine" 45 | task :visualize_ragel => :check_ragel_version do 46 | run_ragel '-V', 'dot' 47 | end 48 | 49 | def run_ragel(options = nil, extension = 'rb') 50 | ragel_file = 'lib/ruby_ami/lexer.rl.rb' 51 | base_file = ragel_file.sub ".rl.rb", "" 52 | command = ["ragel", options, "#{ragel_file} -o #{base_file}.#{extension} 2>&1"].compact.join ' ' 53 | puts "Running command '#{command}'" 54 | puts `#{command}` 55 | raise "Failed generating code from Ragel file #{ragel_file}" if $?.to_i.nonzero? 56 | end 57 | -------------------------------------------------------------------------------- /spec/ruby_ami/async_agi_environment_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe AsyncAGIEnvironmentParser do 6 | let :environment_string do 7 | "agi_request%3A%20async%0Aagi_channel%3A%20SIP%2F1234-00000000%0Aagi_language%3A%20en%0Aagi_type%3A%20SIP%0Aagi_uniqueid%3A%201320835995.0%0Aagi_version%3A%201.8.4.1%0Aagi_callerid%3A%205678%0Aagi_calleridname%3A%20Jane%20Smith%0Aagi_callingpres%3A%200%0Aagi_callingani2%3A%200%0Aagi_callington%3A%200%0Aagi_callingtns%3A%200%0Aagi_dnid%3A%201000%0Aagi_rdnis%3A%20unknown%0Aagi_context%3A%20default%0Aagi_extension%3A%201000%0Aagi_priority%3A%201%0Aagi_enhanced%3A%200.0%0Aagi_accountcode%3A%20%0Aagi_threadid%3A%204366221312%0A%0A" 8 | end 9 | 10 | subject { described_class.new environment_string } 11 | 12 | its(:to_s) { should == environment_string } 13 | its(:to_s) { should_not be environment_string } 14 | 15 | describe 'retrieving a hash representation' do 16 | its(:to_hash) do 17 | should == { 18 | :agi_request => 'async', 19 | :agi_channel => 'SIP/1234-00000000', 20 | :agi_language => 'en', 21 | :agi_type => 'SIP', 22 | :agi_uniqueid => '1320835995.0', 23 | :agi_version => '1.8.4.1', 24 | :agi_callerid => '5678', 25 | :agi_calleridname => 'Jane Smith', 26 | :agi_callingpres => '0', 27 | :agi_callingani2 => '0', 28 | :agi_callington => '0', 29 | :agi_callingtns => '0', 30 | :agi_dnid => '1000', 31 | :agi_rdnis => 'unknown', 32 | :agi_context => 'default', 33 | :agi_extension => '1000', 34 | :agi_priority => '1', 35 | :agi_enhanced => '0.0', 36 | :agi_accountcode => '', 37 | :agi_threadid => '4366221312' 38 | } 39 | end 40 | 41 | it "should not return the same hash object every time" do 42 | subject.to_hash.should_not be subject.to_hash 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/ruby_ami/event_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Event do 6 | describe "equality" do 7 | context "with the same name and the same headers" do 8 | let :event1 do 9 | Event.new('Hangup').tap do |e| 10 | e['Channel'] = 'SIP/101-3f3f' 11 | e['Uniqueid'] = '1094154427.10' 12 | e['Cause'] = '0' 13 | end 14 | end 15 | 16 | let :event2 do 17 | Event.new('Hangup').tap do |e| 18 | e['Channel'] = 'SIP/101-3f3f' 19 | e['Uniqueid'] = '1094154427.10' 20 | e['Cause'] = '0' 21 | end 22 | end 23 | 24 | it "should be equal" do 25 | event1.should be == event2 26 | end 27 | end 28 | 29 | context "with a different name and the same headers" do 30 | let :event1 do 31 | Event.new('Hangup').tap do |e| 32 | e['Channel'] = 'SIP/101-3f3f' 33 | e['Uniqueid'] = '1094154427.10' 34 | e['Cause'] = '0' 35 | end 36 | end 37 | 38 | let :event2 do 39 | Event.new('Foo').tap do |e| 40 | e['Channel'] = 'SIP/101-3f3f' 41 | e['Uniqueid'] = '1094154427.10' 42 | e['Cause'] = '0' 43 | end 44 | end 45 | 46 | it "should not be equal" do 47 | event1.should_not be == event2 48 | end 49 | end 50 | 51 | context "with the same name and different headers" do 52 | let :event1 do 53 | Event.new('Hangup').tap do |e| 54 | e['Channel'] = 'SIP/101-3f3f' 55 | e['Uniqueid'] = '1094154427.10' 56 | e['Cause'] = '0' 57 | end 58 | end 59 | 60 | let :event2 do 61 | Event.new('Hangup').tap do |e| 62 | e['Channel'] = 'SIP/101-3f3f' 63 | e['Uniqueid'] = '1094154427.10' 64 | e['Cause'] = '1' 65 | end 66 | end 67 | 68 | it "should not be equal" do 69 | event1.should_not be == event2 70 | end 71 | end 72 | end 73 | end # Event 74 | end # RubyAMI 75 | -------------------------------------------------------------------------------- /lib/ruby_ami/stream.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | class Stream 4 | class ConnectionStatus 5 | def eql?(other) 6 | other.is_a? self.class 7 | end 8 | 9 | alias :== :eql? 10 | end 11 | 12 | Connected = Class.new ConnectionStatus 13 | Disconnected = Class.new ConnectionStatus 14 | 15 | include Celluloid::IO 16 | 17 | attr_reader :logger 18 | 19 | def initialize(host, port, event_callback, logger = Logger) 20 | super() 21 | @host, @port, @event_callback, @logger = host, port, event_callback, logger 22 | logger.debug "Starting up..." 23 | @lexer = Lexer.new self 24 | end 25 | 26 | [:started, :stopped, :ready].each do |state| 27 | define_method("#{state}?") { @state == state } 28 | end 29 | 30 | def run 31 | @socket = TCPSocket.from_ruby_socket ::TCPSocket.new(@host, @port) 32 | post_init 33 | loop { receive_data @socket.readpartial(4096) } 34 | rescue Errno::ECONNREFUSED, SocketError => e 35 | logger.error "Connection failed due to #{e.class}. Check your config and the server." 36 | current_actor.terminate! 37 | rescue EOFError 38 | logger.info "Client socket closed!" 39 | current_actor.terminate! 40 | end 41 | 42 | def post_init 43 | @state = :started 44 | @event_callback.call Connected.new 45 | end 46 | 47 | def send_data(data) 48 | @socket.write data 49 | end 50 | 51 | def send_action(action) 52 | logger.trace "[SEND] #{action.to_s}" 53 | send_data action.to_s 54 | end 55 | 56 | def receive_data(data) 57 | logger.trace "[RECV] #{data}" 58 | @lexer << data 59 | end 60 | 61 | def message_received(message) 62 | logger.trace "[RECV] #{message.inspect}" 63 | @event_callback.call message 64 | end 65 | 66 | def syntax_error_encountered(ignored_chunk) 67 | logger.error "Encountered a syntax error. Ignoring chunk: #{ignored_chunk.inspect}" 68 | end 69 | 70 | alias :error_received :message_received 71 | 72 | def finalize 73 | logger.debug "Finalizing stream" 74 | @socket.close if @socket 75 | @state = :stopped 76 | @event_callback.call Disconnected.new 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RubyAMI [![Build Status](https://secure.travis-ci.org/adhearsion/ruby_ami.png?branch=master)](http://travis-ci.org/adhearsion/ruby_ami) 2 | RubyAMI is an AMI client library in Ruby and based on EventMachine with the sole purpose of providing an connection to the Asterisk Manager Interface. RubyAMI does not provide any features beyond connection management and protocol parsing. Actions are sent over the wire, and responses come back via callbacks. It's up to you to match these up into something useful. In this regard, RubyAMI is very similar to [Blather](https://github.com/sprsquish/blather) for XMPP or [Punchblock](https://github.com/adhearsion/punchblock), the Ruby 3PCC library. In fact, Punchblock uses RubyAMI under the covers for its Asterisk implementation, including an implementation of AsyncAGI. 3 | 4 | NB: If you're looking to develop an application on Asterisk, you should take a look at the [Adhearsion](http://adhearsion.com) framework first. This library is much lower level. 5 | 6 | ## Installation 7 | gem install ruby_ami 8 | 9 | ## Usage 10 | ```ruby 11 | require 'ruby_ami' 12 | 13 | include RubyAMI 14 | 15 | client = Client.new :username => 'test', 16 | :password => 'test', 17 | :host => '127.0.0.1', 18 | :port => 5038, 19 | :event_handler => lambda { |e| handle_event e }, 20 | :logger => Logger.new(STDOUT), 21 | :log_level => Logger::DEBUG 22 | 23 | def handle_event(event) 24 | case event.name 25 | when 'FullyBooted' 26 | client.send_action 'Originate', 'Channel' => 'SIP/foo' 27 | end 28 | end 29 | 30 | client.start 31 | ``` 32 | 33 | ## Development Requirements 34 | 35 | ruby_ami uses [ragel](http://www.complang.org/ragel/) to generate some of it's 36 | files. 37 | 38 | On OS X (if you use homebrew): 39 | 40 | brew install ragel 41 | 42 | On Linux: 43 | 44 | apt-get install ragel OR yum install ragel 45 | 46 | Once you are inside the repository, before anything else, you will want to run: 47 | 48 | rake ragel 49 | 50 | ## Links: 51 | * [Source](https://github.com/adhearsion/ruby_ami) 52 | * [Documentation](http://rdoc.info/github/adhearsion/ruby_ami/master/frames) 53 | * [Bug Tracker](https://github.com/adhearsion/ruby_ami/issues) 54 | 55 | ## Note on Patches/Pull Requests 56 | 57 | * Fork the project. 58 | * Make your feature addition or bug fix. 59 | * Add tests for it. This is important so I don't break it in a future version unintentionally. 60 | * Commit, do not mess with rakefile, version, or history. 61 | * If you want to have your own version, that is fine but bump version in a commit by itself so I can ignore when I pull 62 | * Send me a pull request. Bonus points for topic branches. 63 | 64 | ## Copyright 65 | 66 | Copyright (c) 2011 Ben Langfeld, Jay Phillips. MIT licence (see LICENSE for details). 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [develop](https://github.com/adhearsion/ruby_ami) 2 | 3 | # [1.2.5](https://github.com/adhearsion/ruby_ami/compare/v1.2.4...v1.2.5) - [2012-10-24](https://rubygems.org/gems/ruby_ami/versions/1.2.5) 4 | * Bugfix: Log wire stuff at trace level 5 | 6 | # [1.2.4](https://github.com/adhearsion/ruby_ami/compare/v1.2.3...v1.2.4) - [2012-10-13](https://rubygems.org/gems/ruby_ami/versions/1.2.4) 7 | * Bugfix: No longer suffer "invalid byte sequence" exceptions due to encoding mismatch. Thanks tboyko 8 | 9 | # [1.2.3](https://github.com/adhearsion/ruby_ami/compare/v1.2.2...v1.2.3) - [2012-09-20](https://rubygems.org/gems/ruby_ami/versions/1.2.3) 10 | * Streams now inherit the client's logger 11 | 12 | # [1.2.2](https://github.com/adhearsion/ruby_ami/compare/v1.2.1...v1.2.2) - [2012-09-05](https://rubygems.org/gems/ruby_ami/versions/1.2.2) 13 | * Streams now log syntax errors 14 | * Celluloid dependency updated 15 | 16 | # [1.2.1](https://github.com/adhearsion/ruby_ami/compare/v1.2.0...v1.2.1) - [2012-07-19](https://rubygems.org/gems/ruby_ami/versions/1.2.1) 17 | * Use SecureRandom for UUIDs 18 | 19 | # [1.2.0](https://github.com/adhearsion/ruby_ami/compare/v1.1.2...v1.2.0) - [2012-07-18](https://rubygems.org/gems/ruby_ami/versions/1.2.0) 20 | * Feature: Added parsers for (Async)AGI environment and result strings 21 | * Bugfix: Avoid a race condition in stream establishment and event receipt 22 | * Bugfix: If socket creation fails, log an appropriate error 23 | 24 | # [1.1.2](https://github.com/adhearsion/ruby_ami/compare/v1.1.1...v1.1.2) - [2012-07-04](https://rubygems.org/gems/ruby_ami/versions/1.1.2) 25 | * Bugfix: Avoid recursive stream stopping 26 | 27 | # [1.1.1](https://github.com/adhearsion/ruby_ami/compare/v1.1.0...v1.1.1) - [2012-06-25](https://rubygems.org/gems/ruby_ami/versions/1.1.1) 28 | * v1.1.0 re-released with fixed celluloid-io dependency 29 | 30 | # [1.1.0](https://github.com/adhearsion/ruby_ami/compare/v1.0.1...v1.1.0) - [2012-06-16](https://rubygems.org/gems/ruby_ami/versions/1.1.0) 31 | * Change: Switch from EventMachine to Celluloid & CelluloidIO for better JRuby compatability and performance (action and events connections are now in separate threads) 32 | 33 | # 1.0.1 - 2012-04-25 34 | * Bugfix: Actions which do not receive a response within 10s will allow further actions to be executed. Synchronous originate has a 60s timeout. 35 | 36 | # 1.0.0 - 2012-03-09 37 | * Bugfix: Remove rcov 38 | * Bump to 1.0.0 since we're in active use 39 | 40 | # 0.1.5 - 2011-12-22 41 | * Bugfix: Work consistently all all versions of Asterisk 42 | * Both 1.8 and 10 43 | * Login actions connection with events turned on (in order to get FullyBooted event) 44 | * Turn events off immediately after fully-booted 45 | * Pass FullyBooted events from the actions connection up to the event handler 46 | 47 | # 0.1.4 - 2011-12-1 48 | * Bugfix: Actions connection should login with Events: System. This ensures that the FullyBooted event will come through on both connections. 49 | 50 | # 0.1.3 - 2011-11-22 51 | * Bugfix: A client can now safely be shut down before it is started, and only performs actions on live streams. 52 | * Bugfix: RubyAMI::Error#inspect now shows an error's message and headers 53 | * Bugfix: Spec and JRuby fixes 54 | 55 | # 0.1.2 56 | * Bugfix: Prevent stream connection status events being passed up to the consumer event handler 57 | * Bugfix: Corrected the README usage docs 58 | * Bugfix: Alias Logger#trace to Logger#debug if the consumer is using a simple logger without a trace level 59 | 60 | # 0.1.1 61 | * Bugfix: Make countdownlatch and i18n runtime dependencies 62 | * Bugfig: Include the generated lexer file in the gem 63 | 64 | # 0.1.0 65 | * Initial release 66 | -------------------------------------------------------------------------------- /features/support/lexer_helper.rb: -------------------------------------------------------------------------------- 1 | FIXTURES = YAML.load_file File.dirname(__FILE__) + "/ami_fixtures.yml" 2 | 3 | def fixture(path, overrides = {}) 4 | path_segments = path.split '/' 5 | selected_event = path_segments.inject(FIXTURES.clone) do |hash, segment| 6 | raise ArgumentError, path + " not found!" unless hash 7 | hash[segment.to_sym] 8 | end 9 | 10 | # Downcase all keys in the event and the overrides 11 | selected_event = selected_event.inject({}) do |downcased_hash,(key,value)| 12 | downcased_hash[key.to_s.downcase] = value 13 | downcased_hash 14 | end 15 | 16 | overrides = overrides.inject({}) do |downcased_hash,(key,value)| 17 | downcased_hash[key.to_s.downcase] = value 18 | downcased_hash 19 | end 20 | 21 | # Replace variables in the selected_event with any overrides, ignoring case of the key 22 | keys_with_variables = selected_event.select { |(key, value)| value.kind_of?(Symbol) || value.kind_of?(Hash) } 23 | 24 | keys_with_variables.each do |original_key, variable_type| 25 | # Does an override an exist in the supplied list? 26 | if overriden_pair = overrides.find { |(key, value)| key == original_key } 27 | # We have an override! Let's replace the template value in the event with the overriden value 28 | selected_event[original_key] = overriden_pair.last 29 | else 30 | # Based on the type, let's generate a placeholder. 31 | selected_event[original_key] = case variable_type 32 | when :string 33 | rand(100000).to_s 34 | when Hash 35 | if variable_type.has_key? "one_of" 36 | # Choose a random possibility 37 | possibilities = variable_type['one_of'] 38 | possibilities[rand(possibilities.size)] 39 | else 40 | raise "Unrecognized Hash fixture property! ##{variable_type.keys.to_sentence}" 41 | end 42 | else 43 | raise "Unrecognized fixture variable type #{variable_type}!" 44 | end 45 | end 46 | end 47 | 48 | hash_to_stanza(selected_event).tap do |event| 49 | selected_event.each_pair do |key, value| 50 | event.meta_def(key) { value } 51 | end 52 | end 53 | end 54 | 55 | def hash_to_stanza(hash) 56 | ordered_hash = hash.to_a 57 | starter = hash.find { |(key, value)| key.strip =~ /^(Response|Action)$/i } 58 | ordered_hash.unshift ordered_hash.delete(starter) if starter 59 | ordered_hash.inject(String.new) do |stanza,(key, value)| 60 | stanza + "#{key}: #{value}\r\n" 61 | end + "\r\n" 62 | end 63 | 64 | def format_newlines(string) 65 | # HOLY FUCK THIS IS UGLY 66 | tmp_replacement = random_string 67 | string.gsub("\r\n", tmp_replacement). 68 | gsub("\n", "\r\n"). 69 | gsub(tmp_replacement, "\r\n") 70 | end 71 | 72 | def random_string 73 | (rand(1_000_000_000_000) + 1_000_000_000).to_s 74 | end 75 | 76 | def follows_body_text(name) 77 | case name 78 | when "ragel_description" 79 | "Ragel is a software development tool that allows user actions to 80 | be embedded into the transitions of a regular expression's corresponding state machine, 81 | eliminating the need to switch from the regular expression engine and user code execution 82 | environment and back again." 83 | when "with_colon_after_first_line" 84 | "Host Username Refresh State Reg.Time \r\nlax.teliax.net:5060 jicksta 105 Registered Tue, 11 Nov 2008 02:29:55" 85 | when "show_channels_from_wayne" 86 | "Channel Location State Application(Data)\r\n0 active channels\r\n0 active calls" 87 | when "empty_string" 88 | "" 89 | end 90 | end 91 | 92 | def syntax_error_data(name) 93 | case name 94 | when "immediate_packet_with_colon" 95 | "!IJ@MHY:!&@B*!B @ ! @^! @ !@ !\r!@ ! @ !@ ! !!m, \n\\n\n" 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/ruby_ami/lexer_machine.rl: -------------------------------------------------------------------------------- 1 | %%{ #% 2 | 3 | ######### 4 | ## This file is written with the Ragel programming language and parses the Asterisk Manager Interface protocol. It depends 5 | ## upon Ragel actions which should be implemented in another Ragel-parsed file which includes this file. 6 | ## 7 | ## Ragel was used because the AMI protocol is extremely non-deterministic and, in the edge cases, requires something both 8 | ## very robust and something which can recover from syntax errors. 9 | ## 10 | ## Note: This file is language agnostic. From this AMI parsers in many other languages can be generated. 11 | ######### 12 | 13 | machine ami_protocol_parser_machine; 14 | 15 | cr = "\r"; # A carriage return. Used before (almost) every newline character. 16 | lf = "\n"; # Newline. Used (with cr) to separate key/value pairs and stanzas. 17 | crlf = cr lf; # Means "carriage return and line feed". Used to separate key/value pairs and stanzas 18 | loose_newline = cr? lf; # Used sometimes when the AMI protocol is nondeterministic about the delimiter 19 | 20 | white = [\t ]; # Single whitespace character, either a tab or a space 21 | colon = ":" [ ]**; # Separates keys from values. "A colon followed by any number of spaces" 22 | stanza_break = crlf crlf; # The seperator between two stanzas. 23 | rest_of_line = (any* -- crlf); # Match all characters until the next line seperator. 24 | 25 | Prompt = "Asterisk Call Manager/" digit+ >version_starts "." digit+ %version_stops crlf; 26 | 27 | Key = ((alnum | print) -- (cr | lf | ":"))+; 28 | KeyValuePair = Key >key_starts %key_stops colon rest_of_line >value_starts %value_stops crlf; 29 | 30 | FollowsDelimiter = loose_newline "--END COMMAND--"; 31 | 32 | Response = "Response"i colon; 33 | 34 | Success = Response "Success"i %init_success crlf @{ fgoto success; }; 35 | Pong = Response "Pong"i %init_success crlf @{ fgoto success; }; 36 | Event = "Event"i colon %event_name_starts rest_of_line %event_name_stops crlf @{ fgoto success; }; 37 | Error = Response "Error"i %init_error crlf (("Message"i colon rest_of_line >error_reason_starts crlf >error_reason_stops) | KeyValuePair)+ crlf @error_received; 38 | Follows = Response "Follows"i crlf @init_response_follows @{ fgoto response_follows; }; 39 | 40 | # For "Response: Follows" 41 | FollowsBody = (any* -- FollowsDelimiter) >follows_text_starts FollowsDelimiter @follows_text_stops crlf; 42 | 43 | ImmediateResponse = (any+ -- (loose_newline | ":")) >immediate_response_starts loose_newline @immediate_response_stops @{fret;}; 44 | SyntaxError = (any+ -- crlf) >syntax_error_starts crlf @syntax_error_stops; 45 | 46 | irregularity := |* 47 | ImmediateResponse; # Performs the fret in the ImmediateResponse FSM 48 | SyntaxError => { fret; }; 49 | *|; 50 | 51 | # When a new socket is established, Asterisk will send the version of the protocol per the Prompt machine. Because it's 52 | # tedious for unit tests to always send this, we'll put some intelligence into this parser to support going straight into 53 | # the protocol-parsing machine. It's also conceivable that a variant of AMI would not send this initial information. 54 | main := |* 55 | Prompt => { fgoto protocol; }; 56 | any => { 57 | # If this scanner's look-ahead capability didn't match the prompt, let's ignore the need for a prompt 58 | fhold; 59 | fgoto protocol; 60 | }; 61 | *|; 62 | 63 | protocol := |* 64 | Prompt; 65 | Success; 66 | Pong; 67 | Event; 68 | Error; 69 | Follows crlf; 70 | crlf => { fgoto protocol; }; # If we get a crlf out of place, let's just ignore it. 71 | any => { 72 | # If NONE of the above patterns match, we consider this a syntax error. The irregularity machine can recover gracefully. 73 | fhold; 74 | fcall irregularity; 75 | }; 76 | *|; 77 | 78 | success := KeyValuePair* crlf @message_received @{fgoto protocol;}; 79 | 80 | # For the "Response: Follows" protocol abnormality. What happens if there's a protocol irregularity in this state??? 81 | response_follows := |* 82 | KeyValuePair+; 83 | FollowsBody; 84 | crlf @{ message_received @current_message; fgoto protocol; }; 85 | *|; 86 | 87 | }%% 88 | -------------------------------------------------------------------------------- /spec/ruby_ami/stream_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Stream do 6 | let(:server_port) { 50000 - rand(1000) } 7 | 8 | before do 9 | def client.message_received(message) 10 | @messages ||= Queue.new 11 | @messages << message 12 | end 13 | 14 | def client.messages 15 | @messages 16 | end 17 | end 18 | 19 | let :client_messages do 20 | messages = [] 21 | messages << client.messages.pop until client.messages.empty? 22 | messages 23 | end 24 | 25 | def mocked_server(times = nil, fake_client = nil, &block) 26 | mock_target = MockServer.new 27 | mock_target.expects(:receive_data).send(*(times ? [:times, times] : [:at_least, 1])).with &block 28 | s = ServerMock.new '127.0.0.1', server_port, mock_target 29 | @stream = Stream.new '127.0.0.1', server_port, lambda { |m| client.message_received m } 30 | @stream.run! 31 | fake_client.call if fake_client.respond_to? :call 32 | Celluloid::Actor.join s 33 | Celluloid::Actor.join @stream 34 | end 35 | 36 | def expect_connected_event 37 | client.expects(:message_received).with Stream::Connected.new 38 | end 39 | 40 | def expect_disconnected_event 41 | client.expects(:message_received).with Stream::Disconnected.new 42 | end 43 | 44 | before { @sequence = 1 } 45 | 46 | describe "after connection" do 47 | it "should be started" do 48 | expect_connected_event 49 | expect_disconnected_event 50 | mocked_server(0) do |val, server| 51 | @stream.started?.should be_true 52 | end 53 | end 54 | 55 | it "can send a command" do 56 | expect_connected_event 57 | expect_disconnected_event 58 | action = Action.new('Command', 'Command' => 'RECORD FILE evil', 'ActionID' => 666, 'Events' => 'On') 59 | mocked_server(1, lambda { @stream.send_action action }) do |val, server| 60 | val.should == action.to_s 61 | end 62 | end 63 | end 64 | 65 | it 'sends events to the client when the stream is ready' do 66 | mocked_server(1, lambda { @stream.send_data 'Foo' }) do |val, server| 67 | server.send_data <<-EVENT 68 | Event: Hangup 69 | Channel: SIP/101-3f3f 70 | Uniqueid: 1094154427.10 71 | Cause: 0 72 | 73 | EVENT 74 | end 75 | 76 | client_messages.should be == [ 77 | Stream::Connected.new, 78 | Event.new('Hangup').tap do |e| 79 | e['Channel'] = 'SIP/101-3f3f' 80 | e['Uniqueid'] = '1094154427.10' 81 | e['Cause'] = '0' 82 | end, 83 | Stream::Disconnected.new 84 | ] 85 | end 86 | 87 | it 'sends responses to the client when the stream is ready' do 88 | mocked_server(1, lambda { @stream.send_data 'Foo' }) do |val, server| 89 | server.send_data <<-EVENT 90 | Response: Success 91 | ActionID: ee33eru2398fjj290 92 | Message: Authentication accepted 93 | 94 | EVENT 95 | end 96 | 97 | client_messages.should be == [ 98 | Stream::Connected.new, 99 | Response.new.tap do |r| 100 | r['ActionID'] = 'ee33eru2398fjj290' 101 | r['Message'] = 'Authentication accepted' 102 | end, 103 | Stream::Disconnected.new 104 | ] 105 | end 106 | 107 | it 'sends error to the client when the stream is ready and a bad command was send' do 108 | client.expects(:message_received).times(3).with do |r| 109 | case @sequence 110 | when 1 111 | r.should be_a Stream::Connected 112 | when 2 113 | r.should be_a Error 114 | r['ActionID'].should == 'ee33eru2398fjj290' 115 | r['Message'].should == 'You stupid git' 116 | when 3 117 | r.should be_a Stream::Disconnected 118 | end 119 | @sequence += 1 120 | end 121 | 122 | mocked_server(1, lambda { @stream.send_data 'Foo' }) do |val, server| 123 | server.send_data <<-EVENT 124 | Response: Error 125 | ActionID: ee33eru2398fjj290 126 | Message: You stupid git 127 | 128 | EVENT 129 | end 130 | end 131 | 132 | it 'puts itself in the stopped state and fires a disconnected event when unbound' do 133 | expect_connected_event 134 | expect_disconnected_event 135 | mocked_server(1, lambda { @stream.send_data 'Foo' }) do |val, server| 136 | @stream.stopped?.should be false 137 | end 138 | @stream.alive?.should be false 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/ruby_ami/action.rb: -------------------------------------------------------------------------------- 1 | module RubyAMI 2 | class Action 3 | attr_reader :name, :headers, :action_id 4 | 5 | attr_accessor :state 6 | 7 | CAUSAL_EVENT_NAMES = %w[queuestatus sippeers iaxpeers parkedcalls dahdishowchannels coreshowchannels 8 | dbget status agents konferencelist] unless defined? CAUSAL_EVENT_NAMES 9 | 10 | def initialize(name, headers = {}, &block) 11 | @name = name.to_s.downcase.freeze 12 | @headers = headers.freeze 13 | @action_id = RubyAMI.new_uuid 14 | @response = FutureResource.new 15 | @response_callback = block 16 | @state = :new 17 | @events = [] 18 | @event_lock = Mutex.new 19 | end 20 | 21 | [:new, :sent, :complete].each do |state| 22 | define_method("#{state}?") { @state == state } 23 | end 24 | 25 | def replies_with_action_id? 26 | !UnsupportedActionName::UNSUPPORTED_ACTION_NAMES.include? name 27 | end 28 | 29 | ## 30 | # When sending an action with "causal events" (i.e. events which must be collected to form a proper 31 | # response), AMI should send a particular event which instructs us that no more events will be sent. 32 | # This event is called the "causal event terminator". 33 | # 34 | # Note: you must supply both the name of the event and any headers because it's possible that some uses of an 35 | # action (i.e. same name, different headers) have causal events while other uses don't. 36 | # 37 | # @param [String] name the name of the event 38 | # @param [Hash] the headers associated with this event 39 | # @return [String] the downcase()'d name of the event name for which to wait 40 | # 41 | def has_causal_events? 42 | CAUSAL_EVENT_NAMES.include? name 43 | end 44 | 45 | ## 46 | # Used to determine the event name for an action which has causal events. 47 | # 48 | # @param [String] action_name 49 | # @return [String] The corresponding event name which signals the completion of the causal event sequence. 50 | # 51 | def causal_event_terminator_name 52 | return unless has_causal_events? 53 | case name 54 | when "sippeers", "iaxpeers" 55 | "peerlistcomplete" 56 | when "dbget" 57 | "dbgetresponse" 58 | when "konferencelist" 59 | "conferencelistcomplete" 60 | else 61 | name + "complete" 62 | end 63 | end 64 | 65 | ## 66 | # Converts this action into a protocol-valid String, ready to be sent over a socket. 67 | # 68 | def to_s 69 | @textual_representation ||= ( 70 | "Action: #{@name}\r\nActionID: #{@action_id}\r\n" + 71 | @headers.map { |(key,value)| "#{key}: #{value}" }.join("\r\n") + 72 | (@headers.any? ? "\r\n\r\n" : "\r\n") 73 | ) 74 | end 75 | 76 | # 77 | # If the response has simply not been received yet from Asterisk, the calling Thread will block until it comes 78 | # in. Once the response comes in, subsequent calls immediately return a reference to the ManagerInterfaceResponse 79 | # object. 80 | # 81 | def response(timeout = nil) 82 | @response.resource(timeout).tap do |resp| 83 | raise resp if resp.is_a? Exception 84 | end 85 | end 86 | 87 | def response=(other) 88 | @state = :complete 89 | @response.resource = other 90 | @response_callback.call other if @response_callback 91 | end 92 | 93 | def <<(message) 94 | case message 95 | when Error 96 | self.response = message 97 | when Event 98 | raise StandardError, 'This action should not trigger events. Maybe it is now a causal action? This is most likely a bug in RubyAMI' unless has_causal_events? 99 | @event_lock.synchronize do 100 | @events << message 101 | end 102 | self.response = @pending_response if message.name.downcase == causal_event_terminator_name 103 | when Response 104 | if has_causal_events? 105 | @pending_response = message 106 | else 107 | self.response = message 108 | end 109 | end 110 | end 111 | 112 | def events 113 | @event_lock.synchronize do 114 | @events.dup 115 | end 116 | end 117 | 118 | def eql?(other) 119 | to_s == other.to_s 120 | end 121 | alias :== :eql? 122 | 123 | def sync_timeout 124 | name.downcase == 'originate' && !headers[:async] ? 60 : 10 125 | end 126 | 127 | ## 128 | # This class will be removed once this AMI library fully supports all known protocol anomalies. 129 | # 130 | class UnsupportedActionName < ArgumentError 131 | UNSUPPORTED_ACTION_NAMES = %w[queues] unless defined? UNSUPPORTED_ACTION_NAMES 132 | 133 | # Blacklist some actions depends on the Asterisk version 134 | def self.preinitialize(version) 135 | if version < 1.8 136 | %w[iaxpeers muteaudio mixmonitormute aocmessage].each do |action| 137 | UNSUPPORTED_ACTION_NAMES << action 138 | end 139 | end 140 | end 141 | 142 | def initialize(name) 143 | super "At the moment this AMI library doesn't support the #{name.inspect} action because it causes a protocol anomaly. Support for it will be coming shortly." 144 | end 145 | end 146 | end 147 | end # RubyAMI 148 | -------------------------------------------------------------------------------- /spec/ruby_ami/action_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Action do 6 | let(:name) { 'foobar' } 7 | let(:headers) { {'foo' => 'bar'} } 8 | 9 | subject do 10 | Action.new name, headers do |response| 11 | @foo = response 12 | end 13 | end 14 | 15 | it { should be_new } 16 | 17 | describe "SIPPeers actions" do 18 | subject { Action.new('SIPPeers') } 19 | its(:has_causal_events?) { should be true } 20 | end 21 | 22 | describe "Queues actions" do 23 | subject { Action.new('Queues') } 24 | its(:replies_with_action_id?) { should == false } 25 | end 26 | 27 | describe "IAXPeers actions" do 28 | before { pending } 29 | # FIXME: This test relies on the side effect that earlier tests have run 30 | # and initialized the UnsupportedActionName::UNSUPPORTED_ACTION_NAMES 31 | # constant for an "unknown" version of Asterisk. This should be fixed 32 | # to be more specific about which version of Asterisk is under test. 33 | # IAXPeers is supported (with Action IDs!) since Asterisk 1.8 34 | subject { Action.new('IAXPeers') } 35 | its(:replies_with_action_id?) { should == false } 36 | end 37 | 38 | describe "the ParkedCalls terminator event" do 39 | subject { Action.new('ParkedCalls') } 40 | its(:causal_event_terminator_name) { should == "parkedcallscomplete" } 41 | end 42 | 43 | it "should properly convert itself into a String when additional headers are given" do 44 | string = Action.new("Hawtsawce", "Monkey" => "Zoo").to_s 45 | string.should =~ /^Action: Hawtsawce\r\n/i 46 | string.should =~ /[^\n]\r\n\r\n$/ 47 | string.should =~ /^(\w+:\s*[\w-]+\r\n){3}\r\n$/ 48 | end 49 | 50 | it "should properly convert itself into a String when no additional headers are given" do 51 | Action.new("Ping").to_s.should =~ /^Action: Ping\r\nActionID: [\w-]+\r\n\r\n$/i 52 | Action.new("ParkedCalls").to_s.should =~ /^Action: ParkedCalls\r\nActionID: [\w-]+\r\n\r\n$/i 53 | end 54 | 55 | it 'should be able to be marked as sent' do 56 | subject.state = :sent 57 | subject.should be_sent 58 | end 59 | 60 | it 'should be able to be marked as complete' do 61 | subject.state = :complete 62 | subject.should be_complete 63 | end 64 | 65 | describe '#<<' do 66 | describe 'for a non-causal action' do 67 | context 'with a response' do 68 | let(:response) { Response.new } 69 | 70 | it 'should set the response' do 71 | subject << response 72 | subject.response.should be response 73 | end 74 | end 75 | 76 | context 'with an error' do 77 | let(:error) { Error.new.tap { |e| e.message = 'AMI error' } } 78 | 79 | it 'should set the response and raise the error when reading it' do 80 | subject << error 81 | lambda { subject.response }.should raise_error Error, 'AMI error' 82 | end 83 | end 84 | 85 | context 'with an event' do 86 | it 'should raise an error' do 87 | lambda { subject << Event.new('foo') }.should raise_error StandardError, /causal action/ 88 | end 89 | end 90 | end 91 | 92 | describe 'for a causal action' do 93 | let(:name) { 'Status' } 94 | 95 | context 'with a response' do 96 | let(:message) { Response.new } 97 | 98 | before { subject << message } 99 | 100 | it { should_not be_complete } 101 | end 102 | 103 | context 'with an event' do 104 | let(:event) { Event.new 'foo' } 105 | 106 | before { subject << event } 107 | 108 | its(:events) { should == [event] } 109 | end 110 | 111 | context 'with a terminating event' do 112 | let(:response) { Response.new } 113 | let(:event) { Event.new 'StatusComplete' } 114 | 115 | before do 116 | subject << response 117 | subject.should_not be_complete 118 | subject << event 119 | end 120 | 121 | its(:events) { should == [event] } 122 | 123 | it { should be_complete } 124 | 125 | its(:response) { should be response } 126 | end 127 | end 128 | end 129 | 130 | describe 'setting the response' do 131 | let(:response) { :bar } 132 | 133 | before { subject.response = response } 134 | 135 | it { should be_complete } 136 | its(:response) { should == response } 137 | 138 | it 'should call the response callback with the response' do 139 | @foo.should == response 140 | end 141 | end 142 | 143 | describe 'comparison' do 144 | describe 'with another Action' do 145 | context 'with identical name and headers' do 146 | let(:other) { Action.new name, headers } 147 | it { should == other } 148 | end 149 | 150 | context 'with identical name and different headers' do 151 | let(:other) { Action.new name, 'boo' => 'baz' } 152 | it { should_not == other } 153 | end 154 | 155 | context 'with different name and identical headers' do 156 | let(:other) { Action.new 'BARBAZ', headers } 157 | it { should_not == other } 158 | end 159 | end 160 | 161 | it { should_not == :foo } 162 | end 163 | 164 | describe "#sync_timeout" do 165 | it "should be 10 seconds" do 166 | subject.sync_timeout.should be == 10 167 | end 168 | 169 | context "for an asynchronous Originate" do 170 | let(:name) { 'Originate' } 171 | let(:headers) { {:async => true} } 172 | 173 | it "should be 60 seconds" do 174 | subject.sync_timeout.should be == 10 175 | end 176 | end 177 | 178 | context "for a synchronous Originate" do 179 | let(:name) { 'Originate' } 180 | let(:headers) { {:async => false} } 181 | 182 | it "should be 60 seconds" do 183 | subject.sync_timeout.should be == 60 184 | end 185 | end 186 | end 187 | end # Action 188 | end # RubyAMI 189 | -------------------------------------------------------------------------------- /lib/ruby_ami/client.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | class Client 4 | attr_reader :options, :action_queue, :events_stream, :actions_stream 5 | 6 | def initialize(options) 7 | @options = options 8 | @logger = options[:logger] || Logger.new(STDOUT) 9 | @logger.level = options[:log_level] || Logger::DEBUG if @logger 10 | @event_handler = @options[:event_handler] 11 | @state = :stopped 12 | 13 | stop_writing_actions 14 | 15 | @pending_actions = {} 16 | @sent_actions = {} 17 | @actions_lock = Mutex.new 18 | 19 | @action_queue = GirlFriday::WorkQueue.new(:actions, :size => 1, :error_handler => ErrorHandler) do |action| 20 | @actions_write_blocker.wait 21 | _send_action action 22 | begin 23 | action.response action.sync_timeout 24 | rescue Timeout::Error => e 25 | logger.error "Timed out waiting for a response to #{action}" 26 | rescue RubyAMI::Error 27 | nil 28 | end 29 | end 30 | 31 | @message_processor = GirlFriday::WorkQueue.new(:messages, :size => 1, :error_handler => ErrorHandler) do |message| 32 | handle_message message 33 | end 34 | 35 | @event_processor = GirlFriday::WorkQueue.new(:events, :size => 2, :error_handler => ErrorHandler) do |event| 36 | handle_event event 37 | end 38 | end 39 | 40 | [:started, :stopped, :ready].each do |state| 41 | define_method("#{state}?") { @state == state } 42 | end 43 | 44 | def start 45 | @events_stream = new_stream lambda { |event| @event_processor << event } 46 | @actions_stream = new_stream lambda { |message| @message_processor << message } 47 | streams.each(&:run!) 48 | @state = :started 49 | streams.each { |s| Celluloid::Actor.join s } 50 | end 51 | 52 | def stop 53 | streams.each do |stream| 54 | begin 55 | stream.terminate if stream.alive? 56 | rescue => e 57 | logger.error e if logger 58 | end 59 | end 60 | end 61 | 62 | def send_action(action, headers = {}, &block) 63 | (action.is_a?(Action) ? action : Action.new(action, headers, &block)).tap do |action| 64 | logger.trace "[QUEUE]: #{action.inspect}" if logger 65 | register_pending_action action 66 | action_queue << action 67 | end 68 | end 69 | 70 | def handle_message(message) 71 | logger.trace "[RECV-ACTIONS]: #{message.inspect}" if logger 72 | case message 73 | when Stream::Connected 74 | login_actions 75 | when Stream::Disconnected 76 | stop_writing_actions 77 | stop 78 | when Event 79 | action = @current_action_with_causal_events 80 | if action 81 | message.action = action 82 | action << message 83 | @current_action_with_causal_events = nil if action.complete? 84 | else 85 | if message.name == 'FullyBooted' 86 | pass_event message 87 | start_writing_actions 88 | else 89 | raise StandardError, "Got an unexpected event on actions socket! This AMI command may have a multi-message response. Try making Adhearsion treat it as causal action #{message.inspect}" 90 | end 91 | end 92 | when Response, Error 93 | action = sent_action_with_id message.action_id 94 | raise StandardError, "Received an AMI response with an unrecognized ActionID!! This may be an bug! #{message.inspect}" unless action 95 | message.action = action 96 | 97 | # By this point the write loop will already have started blocking by calling the response() method on the 98 | # action. Because we must collect more events before we wake the write loop up again, let's create these 99 | # instance variable which will needed when the subsequent causal events come in. 100 | @current_action_with_causal_events = action if action.has_causal_events? 101 | 102 | action << message 103 | end 104 | end 105 | 106 | def handle_event(event) 107 | logger.trace "[RECV-EVENTS]: #{event.inspect}" if logger 108 | case event 109 | when Stream::Connected 110 | login_events 111 | when Stream::Disconnected 112 | stop 113 | else 114 | pass_event event 115 | end 116 | end 117 | 118 | def _send_action(action) 119 | logger.trace "[SEND]: #{action.inspect}" if logger 120 | transition_action_to_sent action 121 | actions_stream.send_action action 122 | action.state = :sent 123 | action 124 | end 125 | 126 | private 127 | 128 | def pass_event(event) 129 | @event_handler.call event if @event_handler.respond_to? :call 130 | end 131 | 132 | def register_pending_action(action) 133 | @actions_lock.synchronize do 134 | @pending_actions[action.action_id] = action 135 | end 136 | end 137 | 138 | def transition_action_to_sent(action) 139 | @actions_lock.synchronize do 140 | @pending_actions.delete action.action_id 141 | @sent_actions[action.action_id] = action 142 | end 143 | end 144 | 145 | def sent_action_with_id(action_id) 146 | @actions_lock.synchronize do 147 | @sent_actions.delete action_id 148 | end 149 | end 150 | 151 | def start_writing_actions 152 | @actions_write_blocker.countdown! 153 | end 154 | 155 | def stop_writing_actions 156 | @actions_write_blocker = CountDownLatch.new 1 157 | end 158 | 159 | def login_actions 160 | action = login_action do |response| 161 | pass_event response if response.is_a? Error 162 | send_action 'Events', 'EventMask' => 'Off' 163 | end 164 | 165 | register_pending_action action 166 | Thread.new { _send_action action } 167 | end 168 | 169 | def login_events 170 | login_action.tap do |action| 171 | events_stream.send_action action 172 | end 173 | end 174 | 175 | def login_action(&block) 176 | Action.new 'Login', 177 | 'Username' => options[:username], 178 | 'Secret' => options[:password], 179 | 'Events' => 'On', 180 | &block 181 | end 182 | 183 | def new_stream(callback) 184 | Stream.new @options[:host], @options[:port], callback, logger 185 | end 186 | 187 | def logger 188 | super 189 | rescue NoMethodError 190 | @logger 191 | end 192 | 193 | def streams 194 | [actions_stream, events_stream].compact 195 | end 196 | 197 | class ErrorHandler 198 | def handle(error) 199 | puts error.message 200 | puts error.backtrace.join("\n") 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /features/step_definitions/lexer_steps.rb: -------------------------------------------------------------------------------- 1 | Given "a new lexer" do 2 | @lexer = IntrospectiveManagerStreamLexer.new 3 | @custom_stanzas = {} 4 | @custom_events = {} 5 | 6 | @GivenPong = lambda do |with_or_without, action_id, number| 7 | number = number == "a" ? 1 : number.to_i 8 | data = case with_or_without 9 | when "with" then "Response: Pong\r\nActionID: #{action_id}\r\n\r\n" 10 | when "without" then "Response: Pong\r\n\r\n" 11 | else raise "Do not recognize preposition #{with_or_without.inspect}. Should be either 'with' or 'without'" 12 | end 13 | number.times do 14 | @lexer << data 15 | end 16 | end 17 | end 18 | 19 | Given "a version header for AMI $version" do |version| 20 | @lexer << "Asterisk Call Manager/1.0\r\n" 21 | end 22 | 23 | Given "a normal login success with events" do 24 | @lexer << fixture('login/standard/success') 25 | end 26 | 27 | Given "a normal login success with events split into two pieces" do 28 | stanza = fixture('login/standard/success') 29 | @lexer << stanza[0...3] 30 | @lexer << stanza[3..-1] 31 | end 32 | 33 | Given "a stanza break" do 34 | @lexer << "\r\n\r\n" 35 | end 36 | 37 | Given "a multi-line Response:Follows body of $method_name" do |method_name| 38 | multi_line_response_body = send(:follows_body_text, method_name) 39 | 40 | multi_line_response = format_newlines(<<-RESPONSE + "\r\n") % multi_line_response_body 41 | Response: Follows\r 42 | Privilege: Command\r 43 | ActionID: 123123\r 44 | %s\r 45 | --END COMMAND--\r\n\r 46 | RESPONSE 47 | 48 | @lexer << multi_line_response 49 | end 50 | 51 | Given "a multi-line Response:Follows response simulating uptime" do 52 | uptime_response = "Response: Follows\r 53 | Privilege: Command\r 54 | System uptime: 46 minutes, 30 seconds\r 55 | --END COMMAND--\r\n\r\n" 56 | @lexer << uptime_response 57 | end 58 | 59 | Given "syntactically invalid $name" do |name| 60 | @lexer << send(:syntax_error_data, name) 61 | end 62 | 63 | Given /^(\d+) Pong responses with an ActionID of ([\d\w.]+)$/ do |number, action_id| 64 | @GivenPong.call "with", action_id, number 65 | end 66 | 67 | Given /^a Pong response with an ActionID of ([\d\w.]+)$/ do |action_id| 68 | @GivenPong.call "with", action_id, 1 69 | end 70 | 71 | Given /^(\d+) Pong responses without an ActionID$/ do |number| 72 | @GivenPong.call "without", Time.now.to_f, number 73 | end 74 | 75 | Given /^a custom stanza named "(\w+)"$/ do |name| 76 | @custom_stanzas[name] = "Response: Success\r\n" 77 | end 78 | 79 | Given 'the custom stanza named "$name" has key "$key" with value "$value"' do |name,key,value| 80 | @custom_stanzas[name] << "#{key}: #{value}\r\n" 81 | end 82 | 83 | Given 'an AMI error whose message is "$message"' do |message| 84 | @lexer << "Response: Error\r\nMessage: #{message}\r\n\r\n" 85 | end 86 | 87 | Given 'an immediate response with text "$text"' do |text| 88 | @lexer << "#{text}\r\n\r\n" 89 | end 90 | 91 | Given 'a custom event with name "$event_name" identified by "$identifier"' do |event_name, identifer| 92 | @custom_events[identifer] = {:Event => event_name } 93 | end 94 | 95 | Given 'a custom header for event identified by "$identifier" whose key is "$key" and value is "$value"' do |identifier, key, value| 96 | @custom_events[identifier][key] = value 97 | end 98 | 99 | Given "an Authentication Required error" do 100 | @lexer << "Response: Error\r\nActionID: BPJeKqW2-SnVg-PyFs-vkXT-7AWVVPD0N3G7\r\nMessage: Authentication Required\r\n\r\n" 101 | end 102 | 103 | Given "a follows packet with a colon in it" do 104 | @lexer << follows_body_text("with_colon") 105 | end 106 | 107 | ######################################## 108 | #### WHEN 109 | ######################################## 110 | 111 | When 'the custom stanza named "$name" is added to the buffer' do |name| 112 | @lexer << (@custom_stanzas[name] + "\r\n") 113 | end 114 | 115 | When 'the custom event identified by "$identifier" is added to the buffer' do |identifier| 116 | custom_event = @custom_events[identifier].clone 117 | event_name = custom_event.delete :Event 118 | stringified_event = "Event: #{event_name}\r\n" 119 | custom_event.each_pair do |key,value| 120 | stringified_event << "#{key}: #{value}\r\n" 121 | end 122 | stringified_event << "\r\n" 123 | @lexer << stringified_event 124 | end 125 | 126 | When "the buffer is lexed" do 127 | @lexer.resume! 128 | end 129 | 130 | ######################################## 131 | #### THEN 132 | ######################################## 133 | 134 | Then "the protocol should have lexed without syntax errors" do 135 | current_pointer = @lexer.send(:instance_variable_get, :@current_pointer) 136 | data_ending_pointer = @lexer.send(:instance_variable_get, :@data_ending_pointer) 137 | current_pointer.should == data_ending_pointer 138 | @lexer.syntax_errors.size.should equal(0) 139 | end 140 | 141 | Then /^the protocol should have lexed with (\d+) syntax errors?$/ do |number| 142 | @lexer.syntax_errors.size.should == number.to_i 143 | end 144 | 145 | Then "the syntax error fixture named $name should have been encountered" do |name| 146 | irregularity = send(:syntax_error_data, name) 147 | @lexer.syntax_errors.find { |error| error == irregularity }.should_not be_nil 148 | end 149 | 150 | Then /^(\d+) messages? should have been received$/ do |number_received| 151 | @lexer.received_messages.size.should == number_received.to_i 152 | end 153 | 154 | Then /^the 'follows' body of (\d+) messages? received should equal (\w+)$/ do |number, method_name| 155 | multi_line_response = follows_body_text method_name 156 | @lexer.received_messages.should_not be_empty 157 | @lexer.received_messages.select do |message| 158 | message.text_body == multi_line_response 159 | end.size.should == number.to_i 160 | end 161 | 162 | Then "the version should be set to $version" do |version| 163 | @lexer.ami_version.should eql(version.to_f) 164 | end 165 | 166 | Then /^the ([\w\d]*) message received should have a key "([^\"]*)" with value "([^\"]*)"$/ do |ordered,key,value| 167 | ordered = ordered[/^(\d+)\w+$/, 1].to_i - 1 168 | @lexer.received_messages[ordered][key].should eql(value) 169 | end 170 | 171 | Then "$number AMI error should have been received" do |number| 172 | @lexer.ami_errors.size.should equal(number.to_i) 173 | end 174 | 175 | Then 'the $order AMI error should have the message "$message"' do |order, message| 176 | order = order[/^(\d+)\w+$/, 1].to_i - 1 177 | @lexer.ami_errors[order].should be_kind_of(RubyAMI::Error) 178 | @lexer.ami_errors[order].message.should eql(message) 179 | end 180 | 181 | Then '$number message should be an immediate response with text "$text"' do |number, text| 182 | matching_immediate_responses = @lexer.received_messages.select do |response| 183 | response.kind_of?(RubyAMI::Response) && response.text_body == text 184 | end 185 | matching_immediate_responses.size.should equal(number.to_i) 186 | matching_immediate_responses.first["ActionID"].should eql(nil) 187 | end 188 | 189 | Then 'the $order event should have the name "$name"' do |order, name| 190 | order = order[/^(\d+)\w+$/, 1].to_i - 1 191 | @lexer.received_messages.select do |response| 192 | response.kind_of?(RubyAMI::Event) 193 | end[order].name.should eql(name) 194 | end 195 | 196 | Then '$number event should have been received' do |number| 197 | @lexer.received_messages.select do |response| 198 | response.kind_of?(RubyAMI::Event) 199 | end.size.should equal(number.to_i) 200 | end 201 | 202 | Then 'the $order event should have key "$key" with value "$value"' do |order, key, value| 203 | order = order[/^(\d+)\w+$/, 1].to_i - 1 204 | @lexer.received_messages.select do |response| 205 | response.kind_of?(RubyAMI::Event) 206 | end[order][key].should eql(value) 207 | end 208 | -------------------------------------------------------------------------------- /lib/ruby_ami/lexer.rl.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module RubyAMI 3 | class Lexer 4 | 5 | KILOBYTE = 1024 6 | BUFFER_SIZE = 128 * KILOBYTE unless defined? BUFFER_SIZE 7 | 8 | ## 9 | # IMPORTANT! See method documentation for adjust_pointers! 10 | # 11 | # @see adjust_pointers 12 | # 13 | POINTERS = [ 14 | :@current_pointer, 15 | :@token_start, 16 | :@token_end, 17 | :@version_start, 18 | :@event_name_start, 19 | :@current_key_position, 20 | :@current_value_position, 21 | :@last_seen_value_end, 22 | :@error_reason_start, 23 | :@follows_text_start, 24 | :@current_syntax_error_start, 25 | :@immediate_response_start 26 | ] 27 | 28 | %%{ 29 | machine ami_protocol_parser; 30 | 31 | # All required Ragel actions are implemented as Ruby methods. 32 | 33 | # Executed after a "Response: Success" or "Response: Pong" 34 | action init_success { init_success } 35 | 36 | action init_response_follows { init_response_follows } 37 | 38 | action init_error { init_error } 39 | 40 | action message_received { message_received @current_message } 41 | action error_received { error_received @current_message } 42 | 43 | action version_starts { version_starts } 44 | action version_stops { version_stops } 45 | 46 | action key_starts { key_starts } 47 | action key_stops { key_stops } 48 | 49 | action value_starts { value_starts } 50 | action value_stops { value_stops } 51 | 52 | action error_reason_starts { error_reason_starts } 53 | action error_reason_stops { error_reason_stops } 54 | 55 | action syntax_error_starts { syntax_error_starts } 56 | action syntax_error_stops { syntax_error_stops } 57 | 58 | action immediate_response_starts { immediate_response_starts } 59 | action immediate_response_stops { immediate_response_stops } 60 | 61 | action follows_text_starts { follows_text_starts } 62 | action follows_text_stops { follows_text_stops } 63 | 64 | action event_name_starts { event_name_starts } 65 | action event_name_stops { event_name_stops } 66 | 67 | include ami_protocol_parser_machine "lexer_machine.rl"; 68 | 69 | }%%## 70 | 71 | attr_accessor :ami_version 72 | 73 | def initialize(delegate = nil) 74 | @delegate = delegate 75 | @data = ''.encode('ISO-8859-1', 'ISO-8859-1') 76 | @current_pointer = 0 77 | @ragel_stack = [] 78 | @ami_version = 0.0 79 | 80 | %%{ 81 | # All other variables become local, letting Ruby garbage collect them. This 82 | # prevents us from having to manually reset them. 83 | 84 | variable data @data; 85 | variable p @current_pointer; 86 | variable pe @data_ending_pointer; 87 | variable cs @current_state; 88 | variable ts @token_start; 89 | variable te @token_end; 90 | variable act @ragel_act; 91 | variable eof @eof; 92 | variable stack @ragel_stack; 93 | variable top @ragel_stack_top; 94 | 95 | write data; 96 | write init; 97 | }%%## 98 | end 99 | 100 | def <<(new_data) 101 | extend_buffer_with new_data 102 | resume! 103 | end 104 | 105 | def resume! 106 | %%{ write exec; }%%## 107 | end 108 | 109 | def extend_buffer_with(new_data) 110 | length = new_data.size 111 | 112 | if length > BUFFER_SIZE 113 | raise Exception, "ERROR: Buffer overrun! Input size (#{new_data.size}) larger than buffer (#{BUFFER_SIZE})" 114 | end 115 | 116 | if length + @data.size > BUFFER_SIZE 117 | if @data.size != @current_pointer 118 | if @current_pointer < length 119 | # We are about to shift more bytes off the array than we have 120 | # parsed. This will cause the parser to lose state so 121 | # integrity cannot be guaranteed. 122 | raise Exception, "ERROR: Buffer overrun! AMI parser cannot guarantee sanity. New data size: #{new_data.size}; Current pointer at #{@current_pointer}; Data size: #{@data.size}" 123 | end 124 | end 125 | @data.slice! 0...length 126 | adjust_pointers -length 127 | end 128 | @data << new_data.encode('ISO-8859-1', 'ISO-8859-1') 129 | @data_ending_pointer = @data.size 130 | end 131 | 132 | protected 133 | 134 | ## 135 | # This method will adjust all pointers into the buffer according 136 | # to the supplied offset. This is necessary any time the buffer 137 | # changes, for example when the sliding window is incremented forward 138 | # after new data is received. 139 | # 140 | # It is VERY IMPORTANT that when any additional pointers are defined 141 | # that they are added to this method. Unpredictable results may 142 | # otherwise occur! 143 | # 144 | # @see https://adhearsion.lighthouseapp.com/projects/5871-adhearsion/tickets/72-ami-lexer-buffer-offset#ticket-72-26 145 | # 146 | # @param offset Adjust pointers by offset. May be negative. 147 | # 148 | def adjust_pointers(offset) 149 | POINTERS.each do |ptr| 150 | value = instance_variable_get(ptr) 151 | instance_variable_set(ptr, value + offset) if !value.nil? 152 | end 153 | end 154 | 155 | ## 156 | # Called after a response or event has been successfully parsed. 157 | # 158 | # @param [Response, Event] message The message just received 159 | # 160 | def message_received(message) 161 | @delegate.message_received message 162 | end 163 | 164 | ## 165 | # Called when there is an Error: stanza on the socket. Could be caused by executing an unrecognized command, trying 166 | # to originate into an invalid priority, etc. Note: many errors' responses are actually tightly coupled to a 167 | # Event which comes directly after it. Often the message will say something like "Channel status 168 | # will follow". 169 | # 170 | # @param [String] reason The reason given in the Message: header for the error stanza. 171 | # 172 | def error_received(message) 173 | @delegate.error_received message 174 | end 175 | 176 | ## 177 | # Called when there's a syntax error on the socket. This doesn't happen as often as it should because, in many cases, 178 | # it's impossible to distinguish between a syntax error and an immediate packet. 179 | # 180 | # @param [String] ignored_chunk The offending text which caused the syntax error. 181 | def syntax_error_encountered(ignored_chunk) 182 | @delegate.syntax_error_encountered ignored_chunk 183 | end 184 | 185 | def init_success 186 | @current_message = Response.new 187 | end 188 | 189 | def init_response_follows 190 | @current_message = Response.new 191 | end 192 | 193 | def init_error 194 | @current_message = Error.new 195 | end 196 | 197 | def version_starts 198 | @version_start = @current_pointer 199 | end 200 | 201 | def version_stops 202 | self.ami_version = @data[@version_start...@current_pointer].to_f 203 | @version_start = nil 204 | end 205 | 206 | def event_name_starts 207 | @event_name_start = @current_pointer 208 | end 209 | 210 | def event_name_stops 211 | event_name = @data[@event_name_start...@current_pointer] 212 | @event_name_start = nil 213 | @current_message = Event.new(event_name) 214 | end 215 | 216 | def key_starts 217 | @current_key_position = @current_pointer 218 | end 219 | 220 | def key_stops 221 | @current_key = @data[@current_key_position...@current_pointer] 222 | end 223 | 224 | def value_starts 225 | @current_value_position = @current_pointer 226 | end 227 | 228 | def value_stops 229 | @current_value = @data[@current_value_position...@current_pointer] 230 | @last_seen_value_end = @current_pointer + 2 # 2 for \r\n 231 | add_pair_to_current_message 232 | end 233 | 234 | def error_reason_starts 235 | @error_reason_start = @current_pointer 236 | end 237 | 238 | def error_reason_stops 239 | @current_message.message = @data[@error_reason_start...@current_pointer] 240 | end 241 | 242 | def follows_text_starts 243 | @follows_text_start = @current_pointer 244 | end 245 | 246 | def follows_text_stops 247 | text = @data[@last_seen_value_end..@current_pointer] 248 | text.sub! /\r?\n--END COMMAND--/, "" 249 | @current_message.text_body = text 250 | @follows_text_start = nil 251 | end 252 | 253 | def add_pair_to_current_message 254 | @current_message[@current_key] = @current_value 255 | reset_key_and_value_positions 256 | end 257 | 258 | def reset_key_and_value_positions 259 | @current_key, @current_value, @current_key_position, @current_value_position = nil 260 | end 261 | 262 | def syntax_error_starts 263 | @current_syntax_error_start = @current_pointer # Adding 1 since the pointer is still set to the last successful match 264 | end 265 | 266 | def syntax_error_stops 267 | # Subtracting 3 from @current_pointer below for "\r\n" which separates a stanza 268 | offending_data = @data[@current_syntax_error_start...@current_pointer - 1] 269 | syntax_error_encountered offending_data 270 | @current_syntax_error_start = nil 271 | end 272 | 273 | def immediate_response_starts 274 | @immediate_response_start = @current_pointer 275 | end 276 | 277 | def immediate_response_stops 278 | message = @data[@immediate_response_start...(@current_pointer -1)] 279 | message_received Response.from_immediate_response(message) 280 | end 281 | 282 | ## 283 | # This method is used primarily in debugging. 284 | # 285 | def view_buffer(message = nil) 286 | message ||= "Viewing the buffer" 287 | 288 | buffer = @data.clone 289 | buffer.insert(@current_pointer, "\033[0;31m\033[1;31m^\033[0m") 290 | 291 | buffer.gsub!("\r", "\\\\r") 292 | buffer.gsub!("\n", "\\n\n") 293 | 294 | puts <<-INSPECTION 295 | VVVVVVVVVVVVVVVVVVVVVVVVVVVVV 296 | #### #{message} 297 | ############################# 298 | #{buffer} 299 | ############################# 300 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 301 | INSPECTION 302 | end 303 | end 304 | end 305 | -------------------------------------------------------------------------------- /spec/ruby_ami/client_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module RubyAMI 5 | describe Client do 6 | let(:event_handler) { [] } 7 | 8 | let(:options) do 9 | { 10 | :host => '127.0.0.1', 11 | :port => 50000 - rand(1000), 12 | :username => 'username', 13 | :password => 'password', 14 | :event_handler => lambda { |event| event_handler << event } 15 | } 16 | end 17 | 18 | subject { Client.new options } 19 | 20 | it { should be_stopped } 21 | 22 | its(:options) { should == options } 23 | 24 | its(:action_queue) { should be_a GirlFriday::WorkQueue } 25 | 26 | its(:streams) { should == [] } 27 | 28 | describe 'starting up' do 29 | before do 30 | ms = MockServer.new 31 | ms.expects(:receive_data).at_least_once 32 | s = ServerMock.new options[:host], options[:port], ms 33 | Thread.new { subject.start } 34 | sleep 0.2 35 | end 36 | 37 | it { should be_started } 38 | 39 | its(:events_stream) { should be_a Stream } 40 | its(:actions_stream) { should be_a Stream } 41 | end 42 | 43 | describe 'logging in streams' do 44 | context 'when the actions stream connects' do 45 | let(:mock_actions_stream) { mock 'Actions Stream' } 46 | 47 | let :expected_login_action do 48 | Action.new 'Login', 49 | 'Username' => 'username', 50 | 'Secret' => 'password', 51 | 'Events' => 'On' 52 | end 53 | 54 | before do 55 | Action.any_instance.stubs(:response).returns(true) 56 | subject.stubs(:actions_stream).returns mock_actions_stream 57 | end 58 | 59 | it 'should log in' do 60 | mock_actions_stream.expects(:send_action).with do |action| 61 | action.to_s.should == expected_login_action.to_s 62 | end 63 | 64 | subject.handle_message(Stream::Connected.new).join 65 | end 66 | end 67 | 68 | context 'when the events stream connects' do 69 | let(:mock_events_stream) { mock 'Events Stream' } 70 | 71 | let :expected_login_action do 72 | Action.new 'Login', 73 | 'Username' => 'username', 74 | 'Secret' => 'password', 75 | 'Events' => 'On' 76 | end 77 | 78 | before do 79 | subject.stubs(:events_stream).returns mock_events_stream 80 | end 81 | 82 | it 'should log in' do 83 | mock_events_stream.expects(:send_action).with expected_login_action 84 | 85 | subject.handle_event Stream::Connected.new 86 | 87 | event_handler.should be_empty 88 | end 89 | end 90 | end 91 | 92 | describe 'when the events stream disconnects' do 93 | it 'should stop' do 94 | subject.expects(:stop).once 95 | subject.handle_event Stream::Disconnected.new 96 | event_handler.should be_empty 97 | end 98 | end 99 | 100 | describe 'when the actions stream disconnects' do 101 | before do 102 | Action.any_instance.stubs(:response).returns(true) 103 | end 104 | 105 | it 'should prevent further actions being sent' do 106 | subject.expects(:_send_action).once 107 | 108 | GirlFriday::WorkQueue.immediate! 109 | subject.handle_message Stream::Connected.new 110 | GirlFriday::WorkQueue.queue! 111 | subject.handle_message Stream::Disconnected.new 112 | 113 | action = Action.new 'foo' 114 | subject.send_action action 115 | 116 | sleep 2 117 | 118 | action.should be_new 119 | end 120 | 121 | it 'should stop' do 122 | subject.expects(:stop).once 123 | subject.handle_message Stream::Disconnected.new 124 | end 125 | end 126 | 127 | describe 'when an event is received' do 128 | let(:event) { Event.new 'foobar' } 129 | 130 | it 'should call the event handler' do 131 | subject.handle_event event 132 | event_handler.should == [event] 133 | end 134 | end 135 | 136 | describe 'when a FullyBooted event is received on the actions connection' do 137 | let(:event) { Event.new 'FullyBooted' } 138 | 139 | let(:mock_actions_stream) { mock 'Actions Stream' } 140 | 141 | let :expected_login_action do 142 | Action.new 'Login', 143 | 'Username' => 'username', 144 | 'Secret' => 'password', 145 | 'Events' => 'On' 146 | end 147 | 148 | let :expected_events_off_action do 149 | Action.new 'Events', 'EventMask' => 'Off' 150 | end 151 | 152 | it 'should call the event handler' do 153 | subject.handle_message event 154 | event_handler.should == [event] 155 | end 156 | 157 | it 'should begin writing actions' do 158 | subject.expects(:start_writing_actions).once 159 | subject.handle_message event 160 | end 161 | 162 | it 'should turn off events' do 163 | Action.any_instance.stubs(:response).returns true 164 | subject.stubs(:actions_stream).returns mock_actions_stream 165 | 166 | mock_actions_stream.expects(:send_action).once.with expected_login_action 167 | mock_actions_stream.expects(:send_action).once.with expected_events_off_action 168 | 169 | login_action = subject.handle_message(Stream::Connected.new).join 170 | login_action.value.response = true 171 | 172 | subject.handle_message event 173 | sleep 0.5 174 | end 175 | end 176 | 177 | describe 'sending actions' do 178 | let(:action_name) { 'Login' } 179 | let :headers do 180 | { 181 | 'Username' => 'username', 182 | 'Secret' => 'password' 183 | } 184 | end 185 | let(:expected_action) { Action.new action_name, headers } 186 | 187 | let :expected_response do 188 | Response.new.tap do |response| 189 | response['ActionID'] = expected_action.action_id 190 | response['Message'] = 'Action completed' 191 | end 192 | end 193 | 194 | let(:mock_actions_stream) { mock 'Actions Stream' } 195 | 196 | before do 197 | subject.stubs(:actions_stream).returns mock_actions_stream 198 | subject.stubs(:login_actions).returns nil 199 | end 200 | 201 | it 'should queue up actions to be sent' do 202 | subject.handle_message Stream::Connected.new 203 | subject.action_queue.expects(:<<).with expected_action 204 | subject.send_action action_name, headers 205 | end 206 | 207 | describe 'forcibly for testing' do 208 | before do 209 | subject.actions_stream.expects(:send_action).with expected_action 210 | subject._send_action expected_action 211 | end 212 | 213 | it 'should mark the action sent' do 214 | expected_action.should be_sent 215 | end 216 | 217 | let(:receive_response) { subject.handle_message expected_response } 218 | 219 | describe 'when a response is received' do 220 | it 'should be sent to the action' do 221 | expected_action.expects(:<<).once.with expected_response 222 | receive_response 223 | end 224 | 225 | it 'should know its action' do 226 | receive_response 227 | expected_response.action.should be expected_action 228 | end 229 | end 230 | 231 | describe 'when an error is received' do 232 | let :expected_response do 233 | Error.new.tap do |response| 234 | response['ActionID'] = expected_action.action_id 235 | response['Message'] = 'Action failed' 236 | end 237 | end 238 | 239 | it 'should be sent to the action' do 240 | expected_action.expects(:<<).once.with expected_response 241 | receive_response 242 | end 243 | 244 | it 'should know its action' do 245 | receive_response 246 | expected_response.action.should be expected_action 247 | end 248 | end 249 | 250 | describe 'when an event is received' do 251 | let(:event) { Event.new 'foo' } 252 | 253 | let(:receive_event) { subject.handle_message event } 254 | 255 | context 'for a causal event' do 256 | let(:expected_action) { Action.new 'Status' } 257 | 258 | it 'should be sent to the action' do 259 | expected_action.expects(:<<).once.with expected_response 260 | expected_action.expects(:<<).once.with event 261 | receive_response 262 | receive_event 263 | end 264 | 265 | it 'should know its action' do 266 | expected_action.stubs :<< 267 | receive_response 268 | receive_event 269 | event.action.should be expected_action 270 | end 271 | end 272 | 273 | context 'for a causal action which is complete' do 274 | let(:expected_action) { Action.new 'Status' } 275 | 276 | before do 277 | expected_action.stubs(:complete?).returns true 278 | end 279 | 280 | it 'should raise an error' do 281 | receive_response 282 | receive_event 283 | lambda { subject.handle_message Event.new('bar') }.should raise_error StandardError, /causal action/ 284 | end 285 | end 286 | 287 | context 'for a non-causal action' do 288 | it 'should raise an error' do 289 | lambda { receive_event }.should raise_error StandardError, /causal action/ 290 | end 291 | end 292 | end 293 | end 294 | 295 | describe 'from the queue' do 296 | it 'should send actions to the stream and set their responses' do 297 | subject.actions_stream.expects(:send_action).with expected_action 298 | subject.handle_message Event.new('FullyBooted') 299 | 300 | Thread.new do 301 | GirlFriday::WorkQueue.immediate! 302 | subject.send_action expected_action 303 | GirlFriday::WorkQueue.queue! 304 | end 305 | 306 | sleep 0.1 307 | 308 | subject.handle_message expected_response 309 | expected_action.response.should be expected_response 310 | end 311 | 312 | it 'should not send another action if the first action has not yet received a response' do 313 | subject.actions_stream.expects(:send_action).once.with expected_action 314 | subject.handle_message Event.new('FullyBooted') 315 | actions = [] 316 | 317 | 2.times do 318 | action = Action.new action_name, headers 319 | actions << action 320 | subject.send_action action 321 | end 322 | 323 | sleep 2 324 | 325 | actions.should have(2).actions 326 | actions[0].should be_sent 327 | actions[1].should be_new 328 | end 329 | end 330 | end 331 | 332 | describe '#stop' do 333 | let(:mock_actions_stream) { mock 'Actions Stream', :alive? => true } 334 | let(:mock_events_stream) { mock 'Events Stream', :alive? => true } 335 | 336 | let(:streams) { [mock_actions_stream, mock_events_stream] } 337 | 338 | before do 339 | subject.stubs(:actions_stream).returns mock_actions_stream 340 | subject.stubs(:events_stream).returns mock_events_stream 341 | end 342 | 343 | it 'should close both streams' do 344 | streams.each { |s| s.expects :terminate } 345 | subject.stop 346 | end 347 | end 348 | end 349 | end 350 | -------------------------------------------------------------------------------- /features/lexer.feature: -------------------------------------------------------------------------------- 1 | Feature: Lexing AMI 2 | As a RubyAMI user 3 | I want to lex the AMI protocol 4 | So that I can control Asterisk asynchronously 5 | 6 | Scenario: Lexing only the initial AMI version header 7 | Given a new lexer 8 | And a version header for AMI 1.0 9 | 10 | When the buffer is lexed 11 | 12 | Then the protocol should have lexed without syntax errors 13 | And the version should be set to 1.0 14 | 15 | Scenario: Lexing the initial AMI header and a login attempt 16 | Given a new lexer 17 | And a version header for AMI 1.0 18 | And a normal login success with events 19 | 20 | When the buffer is lexed 21 | 22 | Then the protocol should have lexed without syntax errors 23 | And 1 message should have been received 24 | 25 | Scenario: Lexing the initial AMI header and then a Response:Follows section 26 | Given a new lexer 27 | And a version header for AMI 1.0 28 | And a multi-line Response:Follows body of ragel_description 29 | 30 | When the buffer is lexed 31 | 32 | Then the protocol should have lexed without syntax errors 33 | And the 'follows' body of 1 message received should equal ragel_description 34 | 35 | Scenario: Lexing a Response:Follows section with no body 36 | Given a new lexer 37 | And a version header for AMI 1.0 38 | And a multi-line Response:Follows body of empty_String 39 | 40 | When the buffer is lexed 41 | 42 | Then the protocol should have lexed without syntax errors 43 | And the 'follows' body of 1 message received should equal empty_string 44 | 45 | Scenario: Lexing a multi-line Response:Follows simulating the "core show channels" command 46 | Given a new lexer 47 | And a version header for AMI 1.0 48 | Given a multi-line Response:Follows body of show_channels_from_wayne 49 | 50 | When the buffer is lexed 51 | 52 | Then the protocol should have lexed without syntax errors 53 | And the 'follows' body of 1 message received should equal show_channels_from_wayne 54 | 55 | Scenario: Lexing a multi-line Response:Follows simulating the "core show uptime" command 56 | Given a new lexer 57 | And a version header for AMI 1.0 58 | Given a multi-line Response:Follows response simulating uptime 59 | 60 | When the buffer is lexed 61 | 62 | Then the protocol should have lexed without syntax errors 63 | And the first message received should have a key "System uptime" with value "46 minutes, 30 seconds" 64 | 65 | Scenario: Lexing a Response:Follows section which has a colon not on the first line 66 | Given a new lexer 67 | And a multi-line Response:Follows body of with_colon_after_first_line 68 | 69 | When the buffer is lexed 70 | 71 | Then the protocol should have lexed without syntax errors 72 | And 1 message should have been received 73 | And the 'follows' body of 1 message received should equal with_colon_after_first_line 74 | 75 | @wip 76 | Scenario: Lexing an immediate response with a colon in it. 77 | Given a new lexer 78 | And an immediate response with text "markq has 0 calls (max unlimited) in 'ringall' strategy (0s holdtime), W:0, C:0, A:0, SL:0.0% within 0s\r\n No Members\r\n No Callers\r\n\r\n\r\n\r\n" 79 | 80 | When the buffer is lexed 81 | 82 | Then the protocol should have lexed without syntax errors 83 | And 1 message should have been received 84 | And 1 message should be an immediate response with text "markq has 0 calls (max unlimited) in 'ringall' strategy (0s holdtime), W:0, C:0, A:0, SL:0.0% within 0s\r\n No Members\r\n No Callers" 85 | 86 | Scenario: Lexing the initial AMI header and then an "Authentication Required" error. 87 | Given a new lexer 88 | And a version header for AMI 1.0 89 | And an Authentication Required error 90 | 91 | When the buffer is lexed 92 | 93 | Then the protocol should have lexed without syntax errors 94 | 95 | Scenario: Lexing the initial AMI header and then a Response:Follows section 96 | Given a new lexer 97 | And a version header for AMI 1.0 98 | And a multi-line Response:Follows body of ragel_description 99 | And a multi-line Response:Follows body of ragel_description 100 | 101 | When the buffer is lexed 102 | 103 | Then the protocol should have lexed without syntax errors 104 | And the 'follows' body of 2 messages received should equal ragel_description 105 | 106 | Scenario: Lexing a stanza without receiving an AMI header 107 | Given a new lexer 108 | And a normal login success with events 109 | 110 | When the buffer is lexed 111 | 112 | Then the protocol should have lexed without syntax errors 113 | And 1 message should have been received 114 | 115 | Scenario: Receiving an immediate response as soon as the socket is opened 116 | Given a new lexer 117 | And an immediate response with text "Immediate responses are so ridiculous" 118 | 119 | When the buffer is lexed 120 | 121 | Then the protocol should have lexed without syntax errors 122 | And 1 message should have been received 123 | And 1 message should be an immediate response with text "Immediate responses are so ridiculous" 124 | 125 | Scenario: Receiving an immediate message surrounded by real messages 126 | Given a new lexer 127 | And a normal login success with events 128 | And an immediate response with text "No queues have been created." 129 | And a normal login success with events 130 | 131 | When the buffer is lexed 132 | 133 | Then the protocol should have lexed without syntax errors 134 | And 3 messages should have been received 135 | And 1 message should be an immediate response with text "No queues have been created." 136 | 137 | Scenario: Receiving a Pong after a simulated login 138 | Given a new lexer 139 | And a version header for AMI 1.0 140 | And a normal login success with events 141 | And a Pong response with an ActionID of randomness 142 | 143 | When the buffer is lexed 144 | 145 | Then the protocol should have lexed without syntax errors 146 | And 2 messages should have been received 147 | 148 | Scenario: Ten Pong responses in a row 149 | Given a new lexer 150 | And 5 Pong responses without an ActionID 151 | And 5 Pong responses with an ActionID of randomness 152 | 153 | When the buffer is lexed 154 | 155 | Then the protocol should have lexed without syntax errors 156 | And 10 messages should have been received 157 | 158 | Scenario: A Pong with an ActionID 159 | Given a new lexer 160 | And a Pong response with an ActionID of 1224469850.61673 161 | 162 | When the buffer is lexed 163 | 164 | Then the first message received should have a key "ActionID" with value "1224469850.61673" 165 | 166 | Scenario: A response containing a floating point value 167 | Given a new lexer 168 | And a custom stanza named "call" 169 | And the custom stanza named "call" has key "ActionID" with value "1224469850.61673" 170 | And the custom stanza named "call" has key "Uniqueid" with value "1173223225.10309" 171 | 172 | When the custom stanza named "call" is added to the buffer 173 | And the buffer is lexed 174 | 175 | Then the 1st message received should have a key "Uniqueid" with value "1173223225.10309" 176 | 177 | Scenario: Receiving a message with custom key/value pairs 178 | Given a new lexer 179 | And a custom stanza named "person" 180 | And the custom stanza named "person" has key "ActionID" with value "1224469850.61673" 181 | And the custom stanza named "person" has key "Name" with value "Jay Phillips" 182 | And the custom stanza named "person" has key "Age" with value "21" 183 | And the custom stanza named "person" has key "Location" with value "San Francisco, CA" 184 | And the custom stanza named "person" has key "x-header" with value "" 185 | And the custom stanza named "person" has key "Channel" with value "IAX2/127.0.0.1/4569-9904" 186 | And the custom stanza named "person" has key "I have spaces" with value "i have trailing padding " 187 | 188 | When the custom stanza named "person" is added to the buffer 189 | And the buffer is lexed 190 | 191 | Then the protocol should have lexed without syntax errors 192 | And the first message received should have a key "Name" with value "Jay Phillips" 193 | And the first message received should have a key "ActionID" with value "1224469850.61673" 194 | And the first message received should have a key "Name" with value "Jay Phillips" 195 | And the first message received should have a key "Age" with value "21" 196 | And the first message received should have a key "Location" with value "San Francisco, CA" 197 | And the first message received should have a key "x-header" with value "" 198 | And the first message received should have a key "Channel" with value "IAX2/127.0.0.1/4569-9904" 199 | And the first message received should have a key "I have spaces" with value "i have trailing padding " 200 | 201 | Scenario: Executing a stanza that was partially received 202 | Given a new lexer 203 | And a normal login success with events split into two pieces 204 | 205 | When the buffer is lexed 206 | 207 | Then the protocol should have lexed without syntax errors 208 | And 1 message should have been received 209 | 210 | Scenario: Receiving an AMI error followed by a normal event 211 | Given a new lexer 212 | And an AMI error whose message is "Missing action in request" 213 | And a normal login success with events 214 | 215 | When the buffer is lexed 216 | 217 | Then the protocol should have lexed without syntax errors 218 | And 1 AMI error should have been received 219 | And the 1st AMI error should have the message "Missing action in request" 220 | And 1 message should have been received 221 | 222 | Scenario: Lexing an immediate response 223 | Given a new lexer 224 | And a normal login success with events 225 | And an immediate response with text "Yes, plain English is sent sometimes over AMI." 226 | And a normal login success with events 227 | 228 | When the buffer is lexed 229 | 230 | Then the protocol should have lexed without syntax errors 231 | And 3 messages should have been received 232 | And 1 message should be an immediate response with text "Yes, plain English is sent sometimes over AMI." 233 | 234 | Scenario: Lexing an AMI event 235 | Given a new lexer 236 | And a custom event with name "NewChannelEvent" identified by "this_event" 237 | And a custom header for event identified by "this_event" whose key is "Foo" and value is "Bar" 238 | And a custom header for event identified by "this_event" whose key is "Channel" and value is "IAX2/127.0.0.1:4569-9904" 239 | And a custom header for event identified by "this_event" whose key is "AppData" and value is "agi://localhost" 240 | 241 | When the custom event identified by "this_event" is added to the buffer 242 | And the buffer is lexed 243 | 244 | Then the protocol should have lexed without syntax errors 245 | And 1 event should have been received 246 | And the 1st event should have the name "NewChannelEvent" 247 | And the 1st event should have key "Foo" with value "Bar" 248 | And the 1st event should have key "Channel" with value "IAX2/127.0.0.1:4569-9904" 249 | And the 1st event should have key "AppData" with value "agi://localhost" 250 | 251 | Scenario: Lexing an immediate packet with a colon in it (syntax error) 252 | Given a new lexer 253 | And syntactically invalid immediate_packet_with_colon 254 | And a stanza break 255 | 256 | When the buffer is lexed 257 | 258 | Then 0 messages should have been received 259 | And the protocol should have lexed with 1 syntax error 260 | And the syntax error fixture named immediate_packet_with_colon should have been encountered 261 | --------------------------------------------------------------------------------