├── 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 |
--------------------------------------------------------------------------------