├── test ├── test_helper.exs ├── speak_ex_test.exs └── router_test.exs ├── .gitignore ├── lib ├── speak_ex.ex └── speak_ex │ ├── agi_result.ex │ ├── outbound_call.ex │ ├── controller │ ├── macros.ex │ └── menu.ex │ ├── utils.ex │ ├── router.ex │ ├── output │ └── swift.ex │ ├── output.ex │ └── call_controller.ex ├── mix.exs ├── mix.lock ├── LICENSE ├── config └── config.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /test/speak_ex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpeakExTest do 2 | use ExUnit.Case 3 | doctest SpeakEx 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/speak_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx do 2 | alias ExAmi.Message 3 | def is_error_message?(message) do 4 | Message.is_response(message) and Message.is_response_error(message) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/speak_ex/agi_result.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.AgiResult do 2 | defstruct result: false, timeout: false, digits: false, offset: false, 3 | data: false, cmd: false, raw: false 4 | 5 | def new({:agiresult, result, timeout, digits, offset, data, cmd, raw}) do 6 | %__MODULE__{result: result, timeout: timeout, digits: digits, 7 | offset: offset, data: data, cmd: cmd, raw: raw} 8 | end 9 | 10 | def new(other) do 11 | other 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :speak_ex, 7 | version: "0.5.0", 8 | elixir: "~> 1.4", 9 | deps: deps(), 10 | package: package(), 11 | name: "Coherence", 12 | description: """ 13 | An Elixir framework for building telephony applications, inspired by Ruby's Adhearsion. 14 | """] 15 | end 16 | 17 | def application do 18 | [applications: [:logger, :erlagi]] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:erlagi, github: "smpallen99/erlagi"}, 24 | {:ex_ami, "~> 0.3"}, 25 | ] 26 | end 27 | 28 | defp package do 29 | [ maintainers: ["Stephen Pallen"], 30 | licenses: ["MIT"], 31 | links: %{ "Github" => "https://github.com/smpallen99/speak_ex"}, 32 | files: ~w(lib README.md mix.exs LICENSE)] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"erlagi": {:git, "https://github.com/smpallen99/erlagi.git", "59ff096dfdb2acaf8a713bf6bdeaa3021d267b3f", []}, 2 | "ex_ami": {:hex, :ex_ami, "0.3.0", "e8666e9d9954effd48e2e95e9f1a6975a91259948b13eaf66d7882d8d86ba3f2", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:gen_state_machine_helpers, "~> 0.1", [hex: :gen_state_machine_helpers, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "gen_fsm": {:hex, :gen_fsm, "0.1.0", "5097308e244e25dbb2aa0ee5b736bb1ab79c3f8ca889a9e5b3aabd8beee6fc88", [:mix], []}, 4 | "gen_state_machine": {:hex, :gen_state_machine, "2.0.0", "f3bc7d961e4cd9f37944b379137e25fd063feffc42800bed69197fd1b78b3151", [], [], "hexpm"}, 5 | "gen_state_machine_helpers": {:hex, :gen_state_machine_helpers, "0.1.0", "d6cd29d6b0a5ffeacd6e6d007733231916c228f2a0f758ff93d5361656a22133", [], [], "hexpm"}, 6 | "goldrush": {:git, "git://github.com/DeadZen/goldrush.git", "64864ba7fcf40988361340e48680b49a2c2938cf", [tag: "0.1.7"]}, 7 | "lager": {:git, "git://github.com/basho/lager.git", "e82bb13efa32a0de747d7dc0e8c567794965cec2", [ref: "master"]}} 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 E-MetroTel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /lib/speak_ex/outbound_call.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.OutboundCall do 2 | 3 | require Logger 4 | 5 | @redirect_number "1" 6 | 7 | def originate(channel, extension, context, priority, variables \\ [], 8 | callback \\ nil) do 9 | 10 | ExAmi.Client.Originate.dial(:asterisk, channel, 11 | {context, extension, priority}, variables, callback, []) 12 | end 13 | 14 | def originate(to, options \\ []) do 15 | context = get_setting(options, :context, "from-internal") 16 | priority = get_setting(options, :priority, "1") 17 | exten = get_setting(options, :exten, @redirect_number) 18 | callback = get_setting(options, :callback, nil) 19 | caller_pid_var = 20 | options 21 | |> Keyword.get(:caller_pid, self()) 22 | |> get_caller_pid_var 23 | 24 | opts = Keyword.drop options, [:event_handler, :context, :priority, 25 | :exten, :callback, :caller_pid] 26 | 27 | ExAmi.Client.Originate.dial(:asterisk, to, {context, exten, priority}, 28 | [caller_pid_var], callback, opts) 29 | end 30 | 31 | defp get_caller_pid_var(pid) do 32 | {"caller_pid", (inspect(pid) |> String.replace("#PID", ""))} 33 | end 34 | 35 | def get_setting(options, key, default) do 36 | Keyword.get(options, key, default) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, 14 | # level: :info 15 | # 16 | # config :logger, :console, 17 | # format: "$date $time [$level] $metadata$message\n", 18 | # metadata: [:user_id] 19 | 20 | config :erlagi, 21 | listen: [ 22 | {:localhost, host: '127.0.0.1', port: 20000, backlog: 5, callback: SpeakEx.CallController} 23 | ] 24 | 25 | 26 | # It is also possible to import configuration files, relative to this 27 | # directory. For example, you can emulate configuration per environment 28 | # by uncommenting the line below and defining dev.exs, test.exs and such. 29 | # Configuration from the imported file will override the ones defined 30 | # here (which is why it is important to import them last). 31 | # 32 | # import_config "#{Mix.env}.exs" 33 | -------------------------------------------------------------------------------- /lib/speak_ex/controller/macros.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.CallController.Macros do 2 | 3 | defmacro __using__(_options) do 4 | quote do 5 | import unquote(__MODULE__) 6 | end 7 | end 8 | 9 | @doc """ 10 | Creates API functions. 11 | 12 | Creates a def name(call) and a def name!(call) function: 13 | 14 | ## Example 15 | 16 | api :answer 17 | 18 | Creates the following two functions: 19 | 20 | def answer(call opts \\ []), do: command(:answer, [call] ++ opts) 21 | def answer!(call, opts \\ []) do 22 | answer(call, opts) 23 | call 24 | end 25 | 26 | """ 27 | defmacro api(name), do: do_api(name, name) 28 | defmacro api(name, command_name), do: do_api(name, command_name) 29 | 30 | defp do_api(name, command_name) do 31 | fun2 = String.to_atom("#{name}!") 32 | quote do 33 | def unquote(name)(call), 34 | do: command(unquote(command_name), [call]) 35 | 36 | def unquote(name)(call, opts) when is_list(opts), 37 | do: command(unquote(command_name), [call] ++ opts) 38 | 39 | def unquote(name)(call, arg), 40 | do: command(unquote(command_name), [call] ++ [arg]) 41 | 42 | def unquote(fun2)(call, opts \\ []) do 43 | unquote(name)(call, opts) 44 | call 45 | end 46 | end 47 | end 48 | 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/speak_ex/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.Utils do 2 | 3 | def any_to_charlist(string) when is_binary(string), 4 | do: String.to_charlist(string) 5 | 6 | def any_to_charlist(list) when is_list(list), 7 | do: list 8 | 9 | def any_to_charlist(int) when is_integer(int), 10 | do: Integer.to_charlist(int) 11 | 12 | def get_channel_variable(call, variable, default \\ nil) 13 | 14 | def get_channel_variable(call, variable, default) when is_binary(variable), 15 | do: get_channel_variable(call, String.to_charlist(variable), default) 16 | 17 | def get_channel_variable(call, variable, default) when is_atom(variable), 18 | do: get_channel_variable(call, Atom.to_charlist(variable), default) 19 | 20 | def get_channel_variable(call, variable, default) do 21 | var = translate_channel_variable variable 22 | list = elem call, 1 23 | case List.keyfind list, 'agi_' ++ var, 0 do 24 | nil -> default 25 | {_, value} -> 26 | value |> List.to_string 27 | end 28 | end 29 | 30 | def translate_channel_variable(var) when var in [:to, :from], 31 | do: translate_channel_variable(Atom.to_charlist var) 32 | 33 | def translate_channel_variable('to'), 34 | do: 'extension' 35 | 36 | def translate_channel_variable('from'), 37 | do: 'callerid' 38 | 39 | def translate_channel_variable(other), 40 | do: other 41 | 42 | end 43 | -------------------------------------------------------------------------------- /lib/speak_ex/router.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.Router do 2 | require Logger 3 | 4 | defmacro __using__(_opts \\ []) do 5 | quote do 6 | import unquote(__MODULE__) 7 | end 8 | end 9 | 10 | defmacro router([do: block]) do 11 | quote do 12 | Module.register_attribute __MODULE__, :routes, 13 | accumulate: true, persist: true 14 | 15 | unless Application.get_env(:speak_ex, :router) do 16 | Application.put_env(:speak_ex, :router, __MODULE__) 17 | end 18 | 19 | unquote(block) 20 | 21 | def do_router(call) do 22 | @routes 23 | |> Enum.reverse 24 | |> Enum.reduce(nil, &SpeakEx.Router.run_route(call, &1, &2)) 25 | end 26 | end 27 | end 28 | 29 | defmacro route(name, module, opts \\ []) do 30 | quote do 31 | Module.put_attribute(__MODULE__, :routes, 32 | {unquote(name), unquote(module), unquote(opts)}) 33 | end 34 | end 35 | 36 | def run_route(call, {_name, module, opts}, nil) do 37 | result = 38 | Enum.all?(opts, fn({k,v}) -> 39 | new_key = SpeakEx.Utils.translate_channel_variable k 40 | SpeakEx.Utils.get_channel_variable(call, new_key) == v 41 | end) 42 | 43 | if result do 44 | {:ok, apply(module, :run, [call])} 45 | else 46 | nil 47 | end 48 | end 49 | 50 | def run_route(_call, _route, result) do 51 | result 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /lib/speak_ex/output/swift.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.Output.Swift do 2 | 3 | @break_x_weak " " 4 | @break_weak " " 5 | @break_medium " " 6 | @break_strong " " 7 | @break_x_strong " " 8 | 9 | @rate_x_slow " " 10 | @rate_slow " " 11 | @rate_medium " " 12 | @rate_fast " " 13 | @rate_x_fast " " 14 | @rate_default " " 15 | 16 | def ssml, do: [ 17 | break: [ 18 | x_weak: @break_x_weak, 19 | weak: @break_weak, 20 | medium: @break_medium, 21 | strong: @break_strong, 22 | x_strong: @break_x_strong, 23 | 24 | phrase: @break_x_weak, 25 | phrase_strong: @break_weak, 26 | sentence: @break_medium, 27 | paragraph: @break_strong, 28 | paragraph_strong: @break_x_strong, 29 | sec: fn(sec) -> " " end, 30 | ms: fn(ms) -> " " end, 31 | ], 32 | speech_rate: [ 33 | x_slow: @rate_x_slow, 34 | slow: @rate_slow, 35 | medium: @rate_medium, 36 | fast: @rate_fast, 37 | x_fast: @rate_x_fast, 38 | default: @rate_default, 39 | value: fn(v) -> " " end, 40 | 41 | half: @rate_x_slow, 42 | two_thirds: @rate_slow, 43 | normal: @rate_default, 44 | third_faster: @rate_fast, 45 | twice_faster: @rate_x_fast, 46 | ] 47 | ] 48 | end 49 | -------------------------------------------------------------------------------- /test/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.RouterTest.Router do 2 | use SpeakEx.Router 3 | 4 | router do 5 | route "From 555", SpeakEx.RouterTest.Router2, from: "555" 6 | route "To 1235", SpeakEx.RouterTest.Router3, to: "1235" 7 | route "Default Route", SpeakEx.RouterTest 8 | end 9 | end 10 | 11 | defmodule SpeakEx.RouterTest.Router2 do 12 | def run(call) do 13 | {:router2, call} 14 | end 15 | end 16 | defmodule SpeakEx.RouterTest.Router3 do 17 | def run(call) do 18 | {:router3, call} 19 | end 20 | end 21 | 22 | defmodule SpeakEx.RouterTest do 23 | use ExUnit.Case 24 | 25 | def run(call) do 26 | call 27 | end 28 | 29 | def test_call, do: {:agicall, 30 | [ 31 | {'agi_network', 'yes'}, 32 | {'agi_request', 'agi://10.30.15.240:20000'}, {'agi_channel', 'SIP/200-00000008'}, 33 | {'agi_language', 'en'}, {'agi_type', 'SIP'}, {'agi_uniqueid', '1442881616.8'}, 34 | {'agi_version', '11.18.0'}, {'agi_callerid', '200'}, {'agi_calleridname', '200'}, 35 | {'agi_callingpres', '0'}, {'agi_callingani2', '0'}, {'agi_callington', '0'}, 36 | {'agi_callingtns', '0'}, {'agi_dnid', '5555'}, {'agi_rdnis', 'unknown'}, 37 | {'agi_context', 'from-internal'}, {'agi_extension', '5555'}, {'agi_priority', '2'}, 38 | {'agi_enhanced', '0.0'}, {'agi_accountcode', []}, {'agi_threadid', '140121545627392'} 39 | ], 40 | :func1, :fun2, :fun3} 41 | 42 | def test_call_2, do: {:agicall, [{'agi_extension', '1234'}, {'agi_callerid', '555'}]} 43 | def test_call_3, do: {:agicall, [{'agi_extension', '1235'}, {'agi_callerid', '444'}]} 44 | 45 | test "gets extension with list" do 46 | assert SpeakEx.Utils.get_channel_variable(test_call(), 'extension') == "5555" 47 | end 48 | test "gets channel with string" do 49 | assert SpeakEx.Utils.get_channel_variable(test_call(), "channel") == "SIP/200-00000008" 50 | end 51 | test "gets callerid with atom" do 52 | assert SpeakEx.Utils.get_channel_variable(test_call(), :callerid) == "200" 53 | end 54 | 55 | test "it finds a default route" do 56 | call = test_call() 57 | assert SpeakEx.RouterTest.Router.do_router(call) == {:ok, call} 58 | end 59 | 60 | test "it finds route with from" do 61 | call = test_call_2() 62 | assert SpeakEx.RouterTest.Router.do_router(call) == {:ok, {:router2, call}} 63 | end 64 | test "it finds route with to" do 65 | call = test_call_3() 66 | assert SpeakEx.RouterTest.Router.do_router(call) == {:ok, {:router3, call}} 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /lib/speak_ex/output.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.Output do 2 | alias SpeakEx.CallController, as: Api 3 | alias SpeakEx.Output.Swift 4 | 5 | require Logger 6 | 7 | def render(call, phrase, opts \\ []) 8 | def render(call, phrase, opts) do 9 | {timeout, digits} = 10 | if Keyword.get opts, :interrupt, :false do 11 | {1, '#'} 12 | else 13 | {Keyword.get(opts, :timeout, nil), Keyword.get(opts, :digits, nil)} 14 | end 15 | 16 | voice = Keyword.get opts, :voice, nil 17 | 18 | num_digits = if digits, do: 1, else: Keyword.get(opts, :num_digits, nil) 19 | 20 | case Application.get_env(:speak_ex, :renderer, :asterisk) do 21 | :asterisk -> 22 | asterisk_stream_file(call, phrase, timeout, digits, voice) 23 | :swift -> 24 | case phrase do 25 | 'file://' ++ filename -> 26 | asterisk_stream_file(call, filename, timeout, digits, voice) 27 | _ -> 28 | swift_stream_file(call, phrase, timeout, num_digits, voice) 29 | end 30 | other -> 31 | throw "Unknown speak_ex renderer #{other}" 32 | end 33 | end 34 | 35 | defp swift_stream_file(call, [phrase | _] = list, timeout, digits, voice) 36 | when not is_integer(phrase) do 37 | 38 | text = 39 | Enum.reduce(list, "", fn(item, acc) -> 40 | separator = if acc == "", do: "", else: Swift.ssml[:break][:sentence] 41 | acc <> separator <> "#{item}" 42 | end) 43 | |> String.to_charlist 44 | 45 | swift_stream_file(call, text, timeout, digits, voice) 46 | end 47 | 48 | defp swift_stream_file(call, phrase, timeout, digits, voice) when is_binary(phrase), 49 | do: swift_stream_file(call, String.to_charlist(phrase), timeout, digits, voice) 50 | 51 | defp swift_stream_file(call, phrase, timeout, digits, voice) when is_binary(voice), 52 | do: swift_stream_file(call, phrase, timeout, digits, String.to_charlist(voice)) 53 | 54 | defp swift_stream_file(call, phrase, timeout, digits, voice) when is_binary(digits), 55 | do: swift_stream_file(call, phrase, timeout, String.to_charlist(digits), voice) 56 | 57 | defp swift_stream_file(call, phrase, timeout, digits, voice) do 58 | append = unless is_nil(digits) do 59 | timeout = if timeout, do: timeout, else: 2000 60 | String.to_charlist "|#{timeout}|#{digits}" 61 | else 62 | '' 63 | end 64 | 65 | prepend = unless is_nil(voice), do: '#{voice}^', else: '' 66 | text = prepend ++ phrase ++ append 67 | 68 | result = 69 | call 70 | |> Api.swift_send(text) 71 | |> SpeakEx.AgiResult.new 72 | 73 | if append != '' do 74 | case Api.get_variable(call, 'SWIFT_DTMF') do 75 | '' -> 76 | %SpeakEx.AgiResult{result | timeout: true} 77 | resp when is_list(resp) -> 78 | data = List.to_string resp 79 | %SpeakEx.AgiResult{result | timeout: false, data: data} 80 | other -> 81 | %SpeakEx.AgiResult{result | result: other, timeout: true} 82 | end 83 | else 84 | call 85 | end 86 | end 87 | 88 | defp asterisk_stream_file(call, prompt, _timeout, digits, voice) do 89 | 90 | if voice, do: raise("voice not valid for asterisk") 91 | 92 | args = if digits, do: [digits], else: [] 93 | 94 | Api.stream_file(call, [prompt | args]) 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # speak_ex 2 | An Elixir framework for building telephony applications, inspired heavily by Ruby's [Adhearsion](http://adhearsion.com/). 3 | 4 | SpeakEx enables easy integration of Elixir and Phoenix voice applications with [Asterisk](http://www.asterisk.org/). For example, build a simple voice survey application with [Elixir Survey Tutorial](https://github.com/smpallen99/elixir_survey_tutorial) or a call out system. 5 | 6 | ## Getting Started 7 | 8 | ### Configure Asterisk 9 | 10 | Configure some extensions to be routed to the SpeakEx application. 11 | 12 | /etc/asterisk/extensions_custom.conf 13 | ``` 14 | [from-internal-custom] 15 | include => speak-ex 16 | 17 | [speak-ex] 18 | exten => _5XXX,1,Noop(SpeakEx Demo) 19 | exten => _5XXX,n,AGI(agi://10.1.2.209:20000) 20 | ``` 21 | 22 | Configure an account for AMI. 23 | 24 | /etc/asterisk/manager.conf 25 | ``` 26 | [elixirconf] 27 | secret = elixirconf 28 | deny=0.0.0.0/0.0.0.0 29 | permit=127.0.0.1/255.255.255.0 30 | read = system,call,log,verbose,command,agent,user,config,command,dtmf,reporting,cdr,dialplan,originate 31 | write = system,call,log,verbose,command,agent,user,config,command,dtmf,reporting,cdr,dialplan,originate 32 | writetimeout = 5000 33 | ``` 34 | 35 | Reload asterisk with `asterisk -rx reload` 36 | 37 | ### Setup your Elixir project 38 | 39 | #### Install the dependency 40 | 41 | mix.exs 42 | ```elixir 43 | ... 44 | {:speak_ex, "~> 0.4"}, 45 | ... 46 | ``` 47 | 48 | Fetch and compile the dependency: 49 | 50 | ``` 51 | mix do deps.get, deps.compile 52 | ``` 53 | 54 | #### Configure AGI and AMI in your elixir project 55 | 56 | SpeakEx uses both ExAmi and erlagi. Configuration is needed for both as follows: 57 | 58 | config/config.exs 59 | ```elixir 60 | config :erlagi, 61 | listen: [ 62 | {:localhost, host: '127.0.0.1', port: 20000, backlog: 5, 63 | callback: SpeakEx.CallController} 64 | ] 65 | 66 | config :ex_ami, 67 | servers: [ 68 | {:asterisk, [ 69 | {:connection, {ExAmi.TcpConnection, [ 70 | {:host, "127.0.0.1"}, {:port, 5038} 71 | ]}}, 72 | {:username, "elixirconf"}, 73 | {:secret, "elixirconf"} 74 | ]} ] 75 | ``` 76 | 77 | #### Configure swift for text-to-speech 78 | 79 | If you want to use text to speech and have [Cepstral](http://www.cepstral.com/) installed on Asterisk, add the following: 80 | 81 | config/config.exs 82 | ```elixir 83 | config :speak_ex, :renderer, :swift 84 | ``` 85 | 86 | #### Create a voice route 87 | 88 | Create a call router to route all incoming calls to the CallController. 89 | 90 | lib/call_router.ex 91 | ```elixir 92 | defmodule Survey.CallRouter do 93 | use SpeakEx.Router 94 | 95 | router do 96 | route "Survey", MyProject.CallController # , to: ~r/5555/ 97 | end 98 | end 99 | ``` 100 | 101 | #### Create a call controller to handle your call 102 | 103 | A sample call controller to answer the call say welcome and hang up. 104 | 105 | lib/call_controller.ex 106 | ```elixir 107 | defmodule MyProject.CallController do 108 | use SpeakEx.CallController 109 | 110 | def run(call) do 111 | call 112 | |> answer! 113 | |> say(welcome) 114 | |> hangup! 115 | |> terminate! 116 | end 117 | end 118 | ``` 119 | 120 | More documentation is coming soon. 121 | 122 | ## License 123 | 124 | `speak_ex` is Copyright (c) 2015-2017 E-MetroTel 125 | 126 | The source code is released under the MIT License. 127 | 128 | Check [LICENSE](LICENSE) for more information. 129 | -------------------------------------------------------------------------------- /lib/speak_ex/call_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.CallController do 2 | use SpeakEx.CallController.Macros 3 | 4 | alias SpeakEx.AgiResult 5 | alias SpeakEx.Output 6 | 7 | require Logger 8 | 9 | defmacro __using__(_options) do 10 | quote do 11 | import unquote(__MODULE__) 12 | end 13 | end 14 | 15 | def command(function, arguments), 16 | do: run_command(:erlagi, function, arguments) 17 | 18 | def originate(to, options \\ [], module) do 19 | metadata = Keyword.get(options, :controller_metadata, []) 20 | controller = Keyword.get(options, :controller, :run) 21 | 22 | Agent.start_link(fn -> 23 | opts = 24 | options 25 | |> Keyword.drop([:controller_metadata, :controller]) 26 | |> Keyword.put(:caller_pid, self()) 27 | 28 | SpeakEx.OutboundCall.originate(to, opts) 29 | 30 | {{module, controller}, metadata} 31 | end) 32 | end 33 | 34 | api :answer 35 | api :hangup 36 | api :terminate 37 | api :enable_music 38 | api :disable_music 39 | api :set_callerid 40 | api :say_digits 41 | api :say_number 42 | api :wait_digit 43 | api :set_variable 44 | api :log_debug 45 | api :log_warn 46 | api :log_notice 47 | api :log_error 48 | api :log_verbose 49 | api :log_dtmf 50 | api :database_deltree 51 | api :set_auto_hangup 52 | api :stream_file 53 | api :play_custom_tones 54 | api :play_busy 55 | api :indicate_busy 56 | api :play_congestion 57 | api :indicate_congestion 58 | api :play_dial 59 | api :stop_play_tones 60 | api :record 61 | api :dial 62 | api :exec 63 | api :swift 64 | 65 | #api :play, :stream_file 66 | 67 | def play(call, [h | _] = filenames) when is_list(h) do 68 | stream_file(call, filenames ++ ['#']) 69 | end 70 | 71 | def play(call, filename) do 72 | stream_file(call, [filename, '#']) 73 | end 74 | 75 | def play!(call, filename_or_list) do 76 | play(call, filename_or_list) 77 | call 78 | end 79 | 80 | def speak(call, phrase, opts \\ []) do 81 | Output.render(call, phrase, opts) 82 | end 83 | 84 | def speak!(call, phrase, opts \\ []) do 85 | speak(call, phrase, opts) 86 | call 87 | end 88 | 89 | def say(call, phrase, opts \\ []), do: speak(call, phrase, opts) 90 | def say!(call, phrase, opts \\ []), do: speak!(call, phrase, opts) 91 | 92 | def swift_send(call, phrase) do 93 | exec(call, ['SWIFT', [phrase]]) 94 | end 95 | 96 | def get_variable(call, variable) when is_binary(variable) do 97 | get_variable(call, String.to_charlist(variable)) 98 | end 99 | 100 | def get_variable(call, variable) do 101 | call 102 | |> :erlagi_io.agi_rw('GET VARIABLE', [variable]) 103 | |> :erlagi_result.get_variable() 104 | end 105 | 106 | ####################### 107 | # Callbacks 108 | 109 | def new_call(call) do 110 | caller_pid = :erlagi.get_variable(call, 'caller_pid') 111 | 112 | if not !!caller_pid do 113 | :speak_ex 114 | |> Application.get_env(:router) 115 | |> apply(:do_router, [call]) 116 | else 117 | agent = :erlang.list_to_pid caller_pid 118 | 119 | case Agent.get(agent, &(&1)) do 120 | {{mod, fun}, metadata} -> 121 | Agent.stop(agent) 122 | apply(mod, fun, [call, metadata]) 123 | other -> 124 | Logger.error "Failed to get from Agent. Result was #{inspect other}" 125 | {:error, "Agent failed. Result was #{inspect other}"} 126 | end 127 | end 128 | end 129 | 130 | ####################### 131 | # Private Helpers 132 | 133 | def run_command(module, function, arguments) do 134 | result = :erlang.apply(module, function, arguments) 135 | AgiResult.new result 136 | end 137 | 138 | end 139 | 140 | 141 | -------------------------------------------------------------------------------- /lib/speak_ex/controller/menu.ex: -------------------------------------------------------------------------------- 1 | defmodule SpeakEx.CallController.Menu do 2 | @moduledoc """ 3 | Defines a voice menu. 4 | 5 | A menu provides a way to play voice prompts to a user and DTMF input 6 | from the user. For example: 7 | 8 | menu "Press 1 for yes, or 2 for no", timeout: 3000, tries: 3 do 9 | match '1', fn -> say "You answered yes" end 10 | match '2', fn -> say "You answered no" end 11 | invalid fn(press) -> say "is invalid" end 12 | timeout fn -> say "times up, try again" end 13 | end 14 | 15 | """ 16 | import SpeakEx.{Utils, CallController, Output} 17 | 18 | alias SpeakEx.{CallController.Menu, AgiResult} 19 | 20 | require Logger 21 | 22 | defmacro __using__(_options) do 23 | quote do 24 | alias SpeakEx.CallController.Menu 25 | import unquote(__MODULE__), only: [menu: 4, match: 2, timeout: 1, 26 | default: 1, invalid: 1, failure: 1] 27 | end 28 | end 29 | 30 | defmacro menu(call, prompt, options \\ [], do: block) do 31 | quote do 32 | var!(__timeout) = nil 33 | var!(__failure) = nil 34 | var!(__invalid) = nil 35 | var!(__matches) = [] 36 | unquote(block) 37 | commands = %{matches: var!(__matches), invalid: var!(__invalid), timeout: var!(__timeout), failure: var!(__failure)} 38 | Menu.do_menu(unquote(call), unquote(prompt), unquote(options), commands, 1) 39 | end 40 | end 41 | 42 | defmacro match(value, fun) do 43 | quote do 44 | var!(__matches) = var!(__matches) ++ [{unquote(value), unquote(fun)}] 45 | end 46 | end 47 | 48 | defmacro timeout(fun) do 49 | quote do 50 | var!(__timeout) = unquote(fun) 51 | end 52 | end 53 | 54 | defmacro default(fun) do 55 | quote do 56 | var!(__invalid) = unquote(fun) 57 | end 58 | end 59 | 60 | defmacro invalid(fun) do 61 | quote do 62 | var!(__invalid) = unquote(fun) 63 | end 64 | end 65 | 66 | defmacro failure(fun) do 67 | quote do 68 | var!(__failure) = unquote(fun) 69 | end 70 | end 71 | 72 | @doc false 73 | def do_menu(call, prompt, options, commands, count) do 74 | tries = Keyword.get(options, :tries, 0) 75 | try do 76 | timeout = Keyword.get(options, :timeout, '-1') 77 | |> any_to_charlist 78 | 79 | get_response(call, prompt, timeout) 80 | |> handle_timeout(call, prompt, options, commands, count, tries) 81 | |> handle_matches(call, prompt, options, commands, count, tries) 82 | rescue 83 | all -> 84 | Logger.error "Exception: #{inspect all}" 85 | commands[:failure] 86 | |> handle_callback(:failure, :ok) 87 | |> handle_call_back_response(call, prompt, options, commands, count, tries) 88 | end 89 | end 90 | 91 | defp get_response(call, prompt, timeout) do 92 | case render(call, prompt, num_digits: 1, digits: '#*1234567890', timeout: timeout) do 93 | %AgiResult{timeout: true} -> 94 | # The user has let the prompt play to completion 95 | case run_command(:erlagi, :wait_digit, [call, timeout]) do 96 | %AgiResult{timeout: false, data: data} -> data 97 | %AgiResult{timeout: true} -> :timeout 98 | end 99 | %AgiResult{data: data} -> 100 | # The user has interrupted the playback 101 | data 102 | end 103 | end 104 | 105 | defp handle_timeout(:timeout, call, prompt, options, commands, count, tries) do 106 | fun = commands[:timeout] 107 | if fun, do: fun.() 108 | unless check_and_handle_failure(call, tries, count, commands), 109 | do: do_menu(call, prompt, options, commands, count + 1) 110 | :timeout 111 | end 112 | defp handle_timeout(press, _call, _prompt, _options, _commands, _count, _tries) do 113 | press 114 | end 115 | 116 | defp handle_matches(:timeout, _call, _prompt, _options, _commands, _count, _tries) do 117 | :timeout 118 | end 119 | defp handle_matches(press, call, prompt, options, commands, count, tries) do 120 | case Enum.find commands[:matches], &(press_valid?(&1, press)) do 121 | nil -> 122 | commands[:invalid] 123 | |> handle_callback(press, :invalid) 124 | |> handle_call_back_response(call, prompt, options, commands, count, tries) 125 | {_value, fun} -> 126 | fun 127 | |> handle_callback(press, :ok) 128 | |> handle_call_back_response(call, prompt, options, commands, count, tries) 129 | end 130 | end 131 | 132 | defp check_and_handle_failure(_call, tries, count, commands) do 133 | if (tries != :infinite) and (count >= tries) do 134 | fun = commands[:failure] 135 | if fun, do: fun.() 136 | true 137 | end 138 | end 139 | 140 | defp handle_callback(fun, press, default) do 141 | cond do 142 | is_function(fun, 0) -> fun.() 143 | is_function(fun, 1) -> fun.(press) 144 | true -> default 145 | end 146 | end 147 | 148 | defp handle_call_back_response(resp, call, prompt, options, commands, count, tries) do 149 | case resp do 150 | :invalid -> 151 | unless check_and_handle_failure(call, tries, count, commands) do 152 | Menu.do_menu(call, prompt, options, commands, count + 1) 153 | else 154 | :ok 155 | end 156 | :repeat -> 157 | do_menu(call, prompt, options, commands, count) 158 | other -> 159 | other 160 | end 161 | end 162 | 163 | defp press_valid?(match, <>) do 164 | press_valid?(match, press) 165 | end 166 | defp press_valid?(match, [press | _]) do 167 | press_valid? match, press 168 | end 169 | defp press_valid?(match, press) do 170 | press in elem(match, 0) 171 | end 172 | 173 | end 174 | --------------------------------------------------------------------------------