├── config ├── prod.exs ├── test.exs ├── dev.exs └── config.exs ├── test ├── test_helper.exs ├── connection_test.exs ├── client_test.exs └── message_test.exs ├── .gitignore ├── lib ├── ex_ami │ ├── client │ │ ├── action.ex │ │ └── originate.ex │ ├── supervisor.ex │ ├── inet_host_ent.ex │ ├── example.ex │ ├── server_config.ex │ ├── ssl_connection.ex │ ├── connection.ex │ ├── tcp_connection.ex │ ├── logger.ex │ ├── reader.ex │ ├── message.ex │ └── client.ex └── ex_ami.ex ├── CHANGELOG.md ├── mix.exs ├── LICENSE ├── mix.lock └── README.md /config/prod.exs: -------------------------------------------------------------------------------- 1 | 2 | use Mix.Config 3 | 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Pavlov.start 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | 2 | use Mix.Config 3 | 4 | config :ex_ami, servers: [] 5 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | 2 | use Mix.Config 3 | 4 | config :ex_ami, 5 | servers: [ 6 | {:asterisk, [ 7 | {:connection, {ExAmi.TcpConnection, [ 8 | {:host, "127.0.0.1"}, {:port, 5038} 9 | ]}}, 10 | {:username, "username"}, 11 | {:secret, "secret"} 12 | ]} ] 13 | -------------------------------------------------------------------------------- /lib/ex_ami/client/action.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Client.Action do 2 | def hangup(client, channel, callback \\ nil) do 3 | action = ExAmi.Message.new_action("Hangup", [{"Channel", channel}]) 4 | pid = Process.whereis(client) 5 | ExAmi.Client.send_action(pid, action, callback) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.ConnectionTest do 2 | use ExUnit.Case, async: true 3 | alias ExAmi.Connection 4 | 5 | test "resolve ip" do 6 | {result, _} = Connection.resolve_host("127.0.0.1") 7 | assert result == :ok 8 | end 9 | 10 | test "resolve hostname" do 11 | {result, _} = Connection.resolve_host("localhost") 12 | assert result == :ok 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ExAmi Changelog 2 | 3 | ## 0.4.3 (2010-10-07) 4 | 5 | ### Enhancements 6 | 7 | ### Bug Fixes 8 | 9 | * Turn response action not found into a warn log instead of an exception 10 | 11 | 12 | ## 0.4.0 (2018-06-29) 13 | 14 | ### Enhancements 15 | 16 | * Added CHANGELOG.md file 17 | * New `ResponseData` Message attribute field. It is filled with the data that does 18 | not match key: value format. 19 | 20 | ### Bug Fixes 21 | 22 | * Fixed parsing Response messages that do not contain Key: Value format. 23 | -------------------------------------------------------------------------------- /lib/ex_ami/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, [], name: :exami_supervisor) 6 | end 7 | 8 | def start_child(server_name, worker_name, server_info), 9 | do: Supervisor.start_child(:exami_supervisor, [server_name, worker_name, server_info]) 10 | 11 | def start_child(server_name), 12 | do: Supervisor.start_child(:exami_supervisor, [server_name]) 13 | 14 | def stop_child(pid) do 15 | Supervisor.terminate_child(:exami_supervisor, pid) 16 | end 17 | 18 | def init([]) do 19 | children = [worker(ExAmi.Client, [])] 20 | supervise(children, strategy: :simple_one_for_one) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/ex_ami/inet_host_ent.ex: -------------------------------------------------------------------------------- 1 | defmodule Inet.HostEnt do 2 | require Record 3 | record = Record.extract(:hostent, from_lib: "kernel/include/inet.hrl") 4 | 5 | keys = :lists.map(&elem(&1, 0), record) 6 | vals = :lists.map(&{&1, [], nil}, keys) 7 | pairs = :lists.zip(keys, vals) 8 | 9 | defstruct keys 10 | @type t :: %__MODULE__{} 11 | 12 | @doc """ 13 | Converts a `Inet.HostEnt` struct to a `:hostent` record. 14 | """ 15 | def to_record(%__MODULE__{unquote_splicing(pairs)}) do 16 | {:hostent, unquote_splicing(vals)} 17 | end 18 | 19 | @doc """ 20 | Converts a `:hostent` record into a `Inet.HostEnt`. 21 | """ 22 | def from_record({:hostent, unquote_splicing(vals)}) do 23 | %__MODULE__{unquote_splicing(pairs)} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ex_ami/example.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Example do 2 | def dial( 3 | server_name, 4 | channel, 5 | extension, 6 | context \\ "from-internal", 7 | priority \\ "1", 8 | variables \\ [] 9 | ) do 10 | ExAmi.Client.Originate.dial( 11 | server_name, 12 | channel, 13 | {context, extension, priority}, 14 | variables, 15 | &__MODULE__.response_callback/2 16 | ) 17 | end 18 | 19 | def response_callback(response, events) do 20 | IO.puts("***************************") 21 | IO.puts(ExAmi.Message.format_log(response)) 22 | 23 | Enum.each(events, fn event -> 24 | IO.puts(ExAmi.Message.format_log(event)) 25 | end) 26 | 27 | IO.puts("***************************") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_ami, 7 | version: "0.5.0", 8 | elixir: "~> 1.5", 9 | package: package(), 10 | name: "ExAmi", 11 | description: """ 12 | An Elixir Asterisk AMI Client Library. 13 | """, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | def application do 19 | [mod: {ExAmi, []}, applications: [:logger, :gen_state_machine]] 20 | end 21 | 22 | defp deps do 23 | [ 24 | {:gen_state_machine, "~> 2.0"}, 25 | {:gen_state_machine_helpers, "~> 0.1"} 26 | ] 27 | end 28 | 29 | defp package do 30 | [ 31 | maintainers: ["Stephen Pallen"], 32 | licenses: ["MIT"], 33 | links: %{"Github" => "https://github.com/smpallen99/ex_ami"}, 34 | files: ~w(lib README.md mix.exs LICENSE) 35 | ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ex_ami/server_config.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.ServerConfig do 2 | use ExAmi.Logger 3 | 4 | def get(server_info, key) do 5 | search(server_info, key) 6 | end 7 | 8 | defp search([], _key), do: nil 9 | defp search([{_, [{k, v} | _]} | _], key) when k == key, do: v 10 | 11 | defp search([{_, [{_, v} | tail2]} | tail], key) do 12 | case search(v, key) do 13 | nil -> 14 | case search(tail2, key) do 15 | nil -> search(tail, key) 16 | other -> other 17 | end 18 | 19 | other -> 20 | other 21 | end 22 | end 23 | 24 | defp search([{k, v} | _], key) when k == key, do: v 25 | defp search([_ | tail], key), do: search(tail, key) 26 | defp search({k, v}, key) when k == key, do: v 27 | defp search({_, [{k, v} | _]}, key) when k == key, do: v 28 | 29 | defp search({_, [{_, v} | tail]}, key) do 30 | case search(v, key) do 31 | nil -> search(tail, key) 32 | other -> other 33 | end 34 | end 35 | 36 | defp search(_, _), do: nil 37 | end 38 | -------------------------------------------------------------------------------- /lib/ex_ami.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | {:ok, pid} = ExAmi.Supervisor.start_link() 6 | 7 | for {name, info} <- Application.get_env(:ex_ami, :servers, []) |> deep_parse() do 8 | worker_name = ExAmi.Client.get_worker_name(name) 9 | ExAmi.Supervisor.start_child(name, worker_name, info) 10 | end 11 | 12 | {:ok, pid} 13 | end 14 | 15 | def deep_parse([]), do: [] 16 | def deep_parse([h | t]), do: [deep_parse(h) | deep_parse(t)] 17 | 18 | def deep_parse({item, list}) when is_list(list) or is_tuple(list), 19 | do: {deep_parse(item), deep_parse(list)} 20 | 21 | def deep_parse({:port, {:system, env}}), 22 | do: {:port, (System.get_env(env) || "5038") |> String.to_integer()} 23 | 24 | def deep_parse({:host, {:system, env}}), do: {:host, System.get_env(env) || "127.0.0.1"} 25 | def deep_parse({item, {:system, env}}), do: {item, System.get_env(env)} 26 | def deep_parse({:system, env}), do: System.get_env(env) 27 | def deep_parse({item, item2}), do: {item, item2} 28 | def deep_parse(item), do: item 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 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 | -------------------------------------------------------------------------------- /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: :debug 15 | # 16 | config :logger, :console, 17 | format: "$date $time [$level] $metadata$message\n", 18 | metadata: [:user_id] 19 | 20 | # It is also possible to import configuration files, relative to this 21 | # directory. For example, you can emulate configuration per environment 22 | # by uncommenting the line below and defining dev.exs, test.exs and such. 23 | # Configuration from the imported file will override the ones defined 24 | # here (which is why it is important to import them last). 25 | # 26 | # import_config "#{Mix.env}.exs" 27 | 28 | 29 | import_config "#{Mix.env}.exs" 30 | -------------------------------------------------------------------------------- /lib/ex_ami/ssl_connection.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.SslConnection do 2 | alias ExAmi.Connection 3 | alias ExAmi.Message 4 | 5 | def open(options) do 6 | host = Keyword.get(options, :host) 7 | port = Keyword.get(options, :port) 8 | {:ok, %Inet.HostEnt{h_addr_list: addresses}} = Connection.resolve_host(host) 9 | {:ok, socket} = real_connect(addresses, port) 10 | 11 | {:ok, 12 | %Connection.Record{ 13 | send: fn data -> __MODULE__.send(socket, data) end, 14 | read_line: fn timeout -> __MODULE__.read_line(socket, timeout) end, 15 | close: fn -> __MODULE__.close(socket) end 16 | }} 17 | end 18 | 19 | def real_connect([], _port), do: :outofaddresses 20 | 21 | def real_connect([address | tail], port) do 22 | case :ssl.connect(address, port, active: false, packet: :line) do 23 | {:ok, socket} -> {:ok, socket} 24 | _ -> real_connect(tail, port) 25 | end 26 | end 27 | 28 | def send(socket, action) do 29 | :ok = :ssl.send(socket, Message.marshall(action)) 30 | end 31 | 32 | def close(socket) do 33 | :ok = :ssl.close(socket) 34 | end 35 | 36 | def read_line(socket, _timeout) do 37 | :ssl.recv(socket, 0, 100) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ex_ami/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Connection do 2 | defmodule Record do 3 | defstruct read_line: &ExAmi.Connection.Record.read_line_not_implemented/1, 4 | send: &__MODULE__.send_not_implemented/1, 5 | close: &__MODULE__.close_not_implemented/1, 6 | parent: nil 7 | 8 | def new(), do: %__MODULE__{} 9 | def new(opts), do: struct(new(), opts) 10 | 11 | def read_line_not_implemented(_), do: :erlang.error('Not implemented') 12 | def send_not_implemented(_), do: :erlang.error('Not implemented') 13 | def close_not_implemented(_), do: :erlang.error('Not implemented') 14 | end 15 | 16 | def behaviour_info(:callbacks), 17 | do: [open: 1, read_line: 2, send: 2, close: 1] 18 | 19 | def behaviour_info(_), do: :undefined 20 | 21 | def resolve_host(host) do 22 | host_list = String.to_charlist(host) 23 | 24 | case :inet.gethostbyaddr(host_list) do 25 | {:ok, resolved} -> {:ok, Inet.HostEnt.from_record(resolved)} 26 | _ -> resolve_host_name(host_list) 27 | end 28 | end 29 | 30 | def resolve_host_name(host) do 31 | case :inet.gethostbyname(host) do 32 | {:ok, resolved} -> {:ok, Inet.HostEnt.from_record(resolved)} 33 | other -> other 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "covertool": {:git, "git://github.com/idubrov/covertool.git", "efac1a45d713690582c0e46f92e042f61d90777a", [ref: "HEAD"]}, 3 | "gen_fsm": {:hex, :gen_fsm, "0.1.0", "5097308e244e25dbb2aa0ee5b736bb1ab79c3f8ca889a9e5b3aabd8beee6fc88", [:mix], []}, 4 | "gen_fsm_helpers": {:hex, :gen_fsm_helpers, "0.1.0", "47734683834c5c43b8395daef098ce285f2c27b26eb40dfd5201f3426c8f750a", [:mix], [], "hexpm"}, 5 | "gen_state_machine": {:hex, :gen_state_machine, "2.0.0", "f3bc7d961e4cd9f37944b379137e25fd063feffc42800bed69197fd1b78b3151", [:mix], [], "hexpm"}, 6 | "gen_state_machine_helpers": {:hex, :gen_state_machine_helpers, "0.1.0", "d6cd29d6b0a5ffeacd6e6d007733231916c228f2a0f758ff93d5361656a22133", [:mix], [], "hexpm"}, 7 | "goldrush": {:git, "git://github.com/DeadZen/goldrush.git", "71e63212f12c25827e0c1b4198d37d5d018a7fec", [tag: "0.1.6"]}, 8 | "lager": {:git, "git://github.com/basho/lager.git", "b7984d4628d0a7780166f3f26207882fddb31251", [ref: "master"]}, 9 | "meck": {:hex, :meck, "0.8.4", "59ca1cd971372aa223138efcf9b29475bde299e1953046a0c727184790ab1520", [:rebar, :make], []}, 10 | "pavlov": {:hex, :pavlov, "0.2.3", "9072029af61301463a6e273e2829e9eab14bd1e7a5c381886d5166e6739e6fcf", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]}, 11 | "rebar": {:git, "https://github.com/rebar/rebar.git", "e9f62c45807ce2db39e0606c4d97cd071416bd64", [tag: "2.5.1"]}, 12 | } 13 | -------------------------------------------------------------------------------- /lib/ex_ami/tcp_connection.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.TcpConnection do 2 | use ExAmi.Logger 3 | alias ExAmi.Connection 4 | alias ExAmi.Message 5 | 6 | def open(options) do 7 | host = Keyword.get(options, :host) 8 | port = Keyword.get(options, :port) 9 | 10 | with {:ok, %Inet.HostEnt{h_addr_list: addresses}} <- Connection.resolve_host(host), 11 | {:ok, socket} <- real_connect(addresses, port) do 12 | {:ok, 13 | %Connection.Record{ 14 | send: fn data -> __MODULE__.send(socket, data) end, 15 | read_line: fn timeout -> __MODULE__.read_line(socket, timeout) end, 16 | close: fn -> __MODULE__.close(socket) end, 17 | parent: self() 18 | }} 19 | else 20 | error -> 21 | error 22 | end 23 | end 24 | 25 | def real_connect([], _port), do: :outofaddresses 26 | 27 | def real_connect([address | tail], port) do 28 | case :gen_tcp.connect( 29 | address, 30 | port, 31 | [:binary] ++ [reuseaddr: true, active: false, packet: :line] 32 | ) do 33 | {:ok, socket} -> {:ok, socket} 34 | _error -> real_connect(tail, port) 35 | end 36 | end 37 | 38 | def send(socket, action) do 39 | :ok = :gen_tcp.send(socket, Message.marshall(action)) 40 | end 41 | 42 | def close(socket) do 43 | :ok = :gen_tcp.close(socket) 44 | end 45 | 46 | def read_line(socket, timeout) do 47 | :gen_tcp.recv(socket, 0, timeout) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ex_ami/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Logger do 2 | defmacro __using__(_opts \\ []) do 3 | quote do 4 | require Logger 5 | alias unquote(__MODULE__), as: Logger 6 | end 7 | end 8 | 9 | defmacro log(level, message) do 10 | quote do 11 | message = unquote(message) 12 | level = unquote(level) 13 | 14 | if Application.get_env(:ex_ami, :logging) do 15 | Logger.log(level, message) 16 | end 17 | end 18 | end 19 | 20 | defmacro error(message, metadata \\ []) do 21 | quote do 22 | message = unquote(message) 23 | metadata = unquote(metadata) 24 | 25 | Logger.error(message, metadata) 26 | end 27 | end 28 | 29 | defmacro warn(message, metadata \\ []) do 30 | quote do 31 | message = unquote(message) 32 | metadata = unquote(metadata) 33 | 34 | Logger.warn(message, metadata) 35 | end 36 | end 37 | 38 | defmacro info(message, metadata \\ []) do 39 | quote do 40 | message = unquote(message) 41 | metadata = unquote(metadata) 42 | 43 | if Application.get_env(:ex_ami, :logging) do 44 | Logger.info(message, metadata) 45 | end 46 | end 47 | end 48 | 49 | defmacro debug(message, metadata \\ []) do 50 | quote do 51 | message = unquote(message) 52 | metadata = unquote(metadata) 53 | 54 | if Application.get_env(:ex_ami, :logging) do 55 | Logger.debug(message, metadata) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ex_ami/client/originate.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Client.Originate do 2 | require Logger 3 | alias ExAmi.Client 4 | 5 | def dial(sever_name, channel, other, variables \\ [], callback \\ nil, opts \\ []) 6 | 7 | def dial(server_name, channel, {context, extension, priority}, variables, callback, opts) do 8 | action_params = %{ 9 | server_name: server_name, 10 | channel: channel, 11 | context: context, 12 | extension: extension, 13 | priority: priority, 14 | variables: variables, 15 | callback: callback, 16 | other: opts 17 | } 18 | 19 | {:ok, client_pid} = ExAmi.Client.start_child(server_name) 20 | 21 | Client.register_listener(client_pid, { 22 | &event_listener(client_pid, &1, &2, action_params), 23 | &(Map.get(&1.attributes, "Event") in ~w(FullyBooted Hangup)) 24 | }) 25 | 26 | {:ok, client_pid} 27 | end 28 | 29 | def dial(server_name, channel, extension, variables, callback, opts), 30 | do: dial(server_name, channel, {"from-internal", extension, "1"}, variables, callback, opts) 31 | 32 | ##################### 33 | # Listener 34 | 35 | def event_listener(client_pid, _server_name, %{attributes: attributes}, action_params) do 36 | case Map.get(attributes, "Event") do 37 | "FullyBooted" -> 38 | send_action(client_pid, action_params) 39 | 40 | "Hangup" -> 41 | %{channel: orig_channel} = action_params 42 | event_channel = Map.get(attributes, "Channel") 43 | 44 | if String.match?(event_channel, ~r/#{orig_channel}/) do 45 | Client.stop(client_pid) 46 | end 47 | end 48 | end 49 | 50 | def send_action(client_pid, %{ 51 | channel: channel, 52 | context: context, 53 | extension: extension, 54 | priority: priority, 55 | variables: variables, 56 | callback: callback, 57 | other: opts 58 | }) do 59 | action = 60 | ExAmi.Message.new_action( 61 | "Originate", 62 | [ 63 | {"Channel", channel}, 64 | {"Exten", extension}, 65 | {"Context", context}, 66 | {"Priority", priority} 67 | ] ++ opts, 68 | variables 69 | ) 70 | 71 | ExAmi.Client.send_action(client_pid, action, callback) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/ex_ami/reader.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Reader do 2 | use ExAmi.Logger 3 | 4 | alias ExAmi.Connection.Record, as: ConnRecord 5 | alias ExAmi.Client 6 | alias ExAmi.Message 7 | 8 | def start_link(client, %ConnRecord{} = connection) do 9 | spawn_link(fn -> 10 | read_salutation(client, connection) 11 | loop(client, connection, "") 12 | end) 13 | end 14 | 15 | def read_salutation(client, connection) do 16 | line = wait_line(connection) 17 | Client.process_salutation(client, line) 18 | end 19 | 20 | def loop(client, connection, acc \\ "") do 21 | new_acc = 22 | case wait_line(connection) do 23 | "\r\n" -> 24 | unmarshalled = ExAmi.Message.unmarshall(acc) 25 | 26 | dispatch_message( 27 | client, 28 | unmarshalled, 29 | Message.is_response(unmarshalled), 30 | Message.is_event(unmarshalled), 31 | acc 32 | ) 33 | 34 | "" 35 | 36 | line -> 37 | acc <> line 38 | end 39 | 40 | loop(client, connection, new_acc) 41 | end 42 | 43 | def dispatch_message(client, response, _, true, _), 44 | do: Client.process_event(client, {:event, response}) 45 | 46 | def dispatch_message(client, response, true, false, _), 47 | do: Client.process_response(client, {:response, response}) 48 | 49 | def dispatch_message(_client, _response, _, _, original), 50 | do: Logger.error("Unknown message: #{inspect(original)}") 51 | 52 | def wait_line(%ConnRecord{read_line: read_line} = connection) do 53 | case read_line.(10) do 54 | {:ok, line} -> 55 | line 56 | 57 | {:error, :timeout} -> 58 | receive do 59 | {:close} -> 60 | Client.socket_close(connection.parent) 61 | Process.sleep(2000) 62 | raise("socket closed") 63 | :erlang.exit(:shutdown) 64 | 65 | :stop -> 66 | %ConnRecord{close: close_fn} = connection 67 | close_fn.() 68 | Process.sleep(2000) 69 | :erlang.exit(:normal) 70 | after 71 | 10 -> 72 | wait_line(connection) 73 | end 74 | 75 | {:error, reason} -> 76 | Client.socket_close(connection.parent) 77 | %ConnRecord{close: close_fn} = connection 78 | close_fn.() 79 | Process.sleep(2000) 80 | :erlang.error(reason) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir Asterisk Management Interface 2 | 3 | An Elixir port of the Erlang Asterisk Manager Interface [erlami](https://github.com/marcelog/erlami) project. 4 | 5 | This version creates a new AMI connection for each call originated, allowing concurrent dialing. 6 | 7 | ## Configuration 8 | 9 | #### Elixir Project 10 | 11 | Add the following to `config/config.exs` 12 | 13 | ``` 14 | config :ex_ami, 15 | servers: [ 16 | {:asterisk, [ 17 | {:connection, {ExAmi.TcpConnection, [ 18 | {:host, "127.0.0.1"}, {:port, 5038} 19 | ]}}, 20 | {:username, "username"}, 21 | {:secret, "secret"} 22 | ]} ] 23 | ``` 24 | 25 | #### Asterisk 26 | 27 | Add the username and secret credentials to `manager.conf` 28 | 29 | ## Installation 30 | 31 | Add ex_ami to your `mix.exs` dependencies and start the application: 32 | 33 | ``` 34 | def application do 35 | [mod: {MyProject, []}, 36 | applications: [:ex_ami]] 37 | end 38 | 39 | defp deps do 40 | [{:ex_ami, "~> 0.4"}] 41 | end 42 | ``` 43 | 44 | ## Example 45 | 46 | ### Listen To All Events 47 | 48 | Use the `ExAmi.Client.register_listener/2` function to register an event listener. 49 | 50 | The second argument to `ExAmi.Client.register_listener` is the tuple {callback, predicate} where: 51 | * `callback` is a function of arity 2 that is called with the server name and the event if the predicate returns true 52 | * `predicate` is a function that is called with the event. Use this function to test the event, returning false/nil if the event should be ignored. 53 | 54 | ``` 55 | defmodule MyModule do 56 | def callback(server, event) do 57 | IO.puts "name: #{inspect server}, event: #{inspect event}" 58 | end 59 | 60 | def start_listening do 61 | ExAmi.Client.register_listener :asterisk, {&MyModule.callback/2, fn(_) -> true end} 62 | end 63 | end 64 | ``` 65 | 66 | ### Originate Call 67 | ``` 68 | defmodule MyDialer do 69 | 70 | def dial(server_name, channel, extension, context \\ "from-internal", 71 | priority \\ "1", variables \\ []) do 72 | 73 | ExAmi.Client.Originate.dial(server_name, channel, 74 | {context, extension, priority}, 75 | variables, &__MODULE__.response_callback/2) 76 | end 77 | def response_callback(response, events) do 78 | IO.puts "***************************" 79 | IO.puts ExAmi.Message.format_log(response) 80 | Enum.each events, fn(event) -> 81 | IO.puts ExAmi.Message.format_log(event) 82 | end 83 | IO.puts "***************************" 84 | end 85 | 86 | end 87 | ``` 88 | 89 | To originate a 3rd party call from extensions 100 to 101: 90 | 91 | ``` 92 | iex> MyDialer.dial(:asterisk, "SIP/100", "101") 93 | 94 | ``` 95 | ## Trouble Shooting 96 | 97 | * Ensure you start the ex_ami application in your mix.exs file as described above 98 | * Enusre you setup the ex_ami configuration with the correct credentials 99 | * Enusre you setup the credentials for the AMI connection in Asterisk 100 | 101 | ## License 102 | 103 | ex_ami is Copyright (c) 2015-2018 E-MetroTel 104 | 105 | The source code is released under the MIT License. 106 | 107 | Check [LICENSE](LICENSE) for more information. 108 | -------------------------------------------------------------------------------- /test/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.ClientTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ExAmi.{Client, Message} 5 | 6 | setup do 7 | pid = self() 8 | 9 | connection = %{ 10 | send: fn msg -> 11 | send(pid, {:connection, msg}) 12 | :ok 13 | end 14 | } 15 | 16 | {:ok, state: %Client.ClientState{connection: connection}} 17 | end 18 | 19 | test "handle response invalid action does not raise", %{state: state} do 20 | message = Message.new_action("test") 21 | {:next_state, :receiving, new_state} = Client.receiving(:cast, {:response, message}, state) 22 | assert new_state == state 23 | end 24 | 25 | test "handle duplicate action", %{state: state} do 26 | pid = self() 27 | callback = &send(pid, {:callback, &1, &2}) 28 | action = Message.new_action("QueueStatus") 29 | action_id = action.attributes["ActionID"] 30 | 31 | {:next_state, :receiving, state} = Client.receiving(:cast, {:action, action, callback}, state) 32 | 33 | assert state.actions == %{ 34 | action_id => {action, :none, [], callback} 35 | } 36 | 37 | assert_receive {:connection, ^action} 38 | 39 | action_id_alt = action_id <> "_alt" 40 | 41 | {:next_state, :receiving, state} = Client.receiving(:cast, {:action, action, callback}, state) 42 | 43 | action2 = Message.put(action, "ActionID", action_id_alt) 44 | 45 | assert state.actions == %{ 46 | action_id => {action, :none, [], callback}, 47 | action_id_alt => {action2, :none, [], callback} 48 | } 49 | 50 | assert_receive {:connection, ^action2} 51 | end 52 | 53 | test "handle QueueStatusResponse", %{state: state} do 54 | pid = self() 55 | callback = &send(pid, {:callback, &1, &2}) 56 | action = Message.new_action("QueueStatus") 57 | action_id = action.attributes["ActionID"] 58 | 59 | {:next_state, :receiving, state} = Client.receiving(:cast, {:action, action, callback}, state) 60 | 61 | assert state.actions == %{ 62 | action_id => {action, :none, [], callback} 63 | } 64 | 65 | assert_receive {:connection, ^action} 66 | 67 | event1 = 68 | unmarshall(""" 69 | Response: Success 70 | ActionID: #{action_id} 71 | EventList: start 72 | Message: Queue status will follow 73 | 74 | """) 75 | 76 | {:next_state, :receiving, state} = Client.receiving(:cast, {:event, event1}, state) 77 | 78 | assert state.actions == %{ 79 | action_id => {action, :none, [event1], callback} 80 | } 81 | 82 | event2 = 83 | unmarshall(""" 84 | Event: QueueParams 85 | Queue: 500 86 | Calls: 0 87 | ActionID: #{action_id} 88 | 89 | """) 90 | 91 | {:next_state, :receiving, state} = Client.receiving(:cast, {:event, event2}, state) 92 | 93 | assert state.actions == %{ 94 | action_id => {action, :none, [event2, event1], callback} 95 | } 96 | 97 | event3 = 98 | unmarshall(""" 99 | Event: QueueMember 100 | ActionID: #{action_id} 101 | Queue: 500 102 | Name: 2000 103 | 104 | """) 105 | 106 | {:next_state, :receiving, state} = Client.receiving(:cast, {:event, event3}, state) 107 | 108 | assert state.actions == %{ 109 | action_id => {action, :none, [event3, event2, event1], callback} 110 | } 111 | 112 | event4 = 113 | unmarshall(""" 114 | Event: QueueStatusComplete 115 | ActionID: #{action_id} 116 | EventList: Complete 117 | ListItems: 2 118 | 119 | """) 120 | 121 | {:next_state, :receiving, state} = Client.receiving(:cast, {:event, event4}, state) 122 | 123 | assert state.actions == %{} 124 | 125 | expected = [event1, event2, event3, event4] 126 | assert_receive {:callback, :none, ^expected} 127 | 128 | refute_receive _, 2 129 | end 130 | 131 | defp unmarshall(text) do 132 | text = String.replace(text, "\n", "\r\n") 133 | Message.unmarshall(text) 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/ex_ami/message.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Message do 2 | use ExAmi.Logger 3 | 4 | @eol "\r\n" 5 | 6 | defmodule Message do 7 | defstruct attributes: %{}, variables: %{} 8 | 9 | def new, do: %__MODULE__{} 10 | 11 | def new(attributes, variables), 12 | do: %__MODULE__{attributes: attributes, variables: variables} 13 | 14 | def new(opts), do: struct(new(), opts) 15 | end 16 | 17 | def new_message, do: Message.new() 18 | def new_message(attributes, variables), do: Message.new(attributes, variables) 19 | 20 | def new_action(name) do 21 | action_id = to_string(:erlang.monotonic_time()) <> to_string(:rand.uniform(1000)) 22 | set_all(new_message(), [{"Action", name}, {"ActionID", action_id}]) 23 | end 24 | 25 | def new_action(name, attributes) do 26 | name 27 | |> new_action() 28 | |> set_all(attributes) 29 | end 30 | 31 | def new_action(name, attributes, variables) do 32 | name 33 | |> new_action() 34 | |> set_all(attributes) 35 | |> set_all_variables(variables) 36 | end 37 | 38 | def get(%Message{attributes: attributes}, key) do 39 | case Map.fetch(attributes, key) do 40 | {:ok, value} -> {:ok, value} 41 | _ -> :notfound 42 | end 43 | end 44 | 45 | def put(%Message{attributes: attributes} = message, key, value) do 46 | %{message | attributes: Map.put(attributes, key, value)} 47 | end 48 | 49 | def get_variable(%Message{variables: variables}, key) do 50 | case Map.fetch(variables, key) do 51 | {:ok, value} -> {:ok, value} 52 | _ -> :notfound 53 | end 54 | end 55 | 56 | def set(key, value) do 57 | set(new_message(), key, value) 58 | end 59 | 60 | def set(%Message{} = message, key, value) do 61 | message.attributes 62 | |> Map.put(key, value) 63 | |> new_message(message.variables) 64 | end 65 | 66 | def add_response_data(%Message{attributes: attributes} = message, line) do 67 | response_data = 68 | case attributes["ResponseData"] do 69 | nil -> line 70 | acc -> acc <> "\n" <> line 71 | end 72 | 73 | %Message{message | attributes: Map.put(attributes, "ResponseData", response_data)} 74 | end 75 | 76 | def set_all(%Message{} = message, attributes) do 77 | Enum.reduce(attributes, message, fn {key, value}, acc -> set(acc, key, value) end) 78 | end 79 | 80 | def set_variable(%Message{variables: variables, attributes: attributes}, key, value), 81 | do: new_message(attributes, Map.put(variables, key, value)) 82 | 83 | def set_all_variables(%Message{} = message, variables) do 84 | Enum.reduce(variables, message, fn {key, value}, acc -> set_variable(acc, key, value) end) 85 | end 86 | 87 | def marshall(%Message{attributes: attributes, variables: variables}) do 88 | Enum.reduce(Map.to_list(attributes), "", fn {k, v}, acc -> marshall(acc, k, v) end) <> 89 | Enum.reduce(Map.to_list(variables), "", fn {k, v}, acc -> marshall_variable(acc, k, v) end) <> 90 | @eol 91 | end 92 | 93 | def marshall(key, value), do: key <> ": " <> value <> @eol 94 | def marshall(acc, key, value), do: acc <> marshall(key, value) 95 | 96 | def marshall_variable(key, value), do: marshall("Variable", key <> "=" <> value) 97 | def marshall_variable(acc, key, value), do: acc <> marshall("Variable", key <> "=" <> value) 98 | 99 | def explode_lines(text), do: String.split(text, "\r\n", trim: true) 100 | 101 | def format_log(%{attributes: attributes}) do 102 | cond do 103 | value = Map.get(attributes, "Event") -> 104 | format_log("Event", value, attributes) 105 | 106 | value = Map.get(attributes, "Response") -> 107 | format_log("Response", value, attributes) 108 | 109 | true -> 110 | {:error, :notfound} 111 | end 112 | end 113 | 114 | def format_log(key, value, attributes) do 115 | attributes 116 | |> Map.delete(key) 117 | |> Map.to_list() 118 | |> Enum.reduce(key <> ": \"" <> value <> "\"", fn {k, v}, acc -> 119 | acc <> ", " <> k <> ": \"" <> v <> "\"" 120 | end) 121 | end 122 | 123 | def unmarshall(text) do 124 | do_unmarshall(new_message(), explode_lines(text)) 125 | end 126 | 127 | defp do_unmarshall(message, []), do: message 128 | 129 | defp do_unmarshall(message, [line | tail]) do 130 | ~r/^([^\s]+): (.*)/ 131 | |> Regex.run(line) 132 | |> case do 133 | [_, key, value] -> 134 | set(message, key, value) 135 | 136 | nil -> 137 | add_response_data(message, line) 138 | end 139 | |> do_unmarshall(tail) 140 | end 141 | 142 | def is_response(%Message{} = message), do: is_type(message, "Response") 143 | def is_event(%Message{} = message), do: is_type(message, "Event") 144 | 145 | def is_response_success(%Message{} = message) do 146 | {:ok, value} = get(message, "Response") 147 | value == "Success" 148 | end 149 | 150 | def is_response_error(%Message{} = message) do 151 | {:ok, value} = get(message, "Response") 152 | value == "Error" 153 | end 154 | 155 | def is_response_complete(%Message{} = message) do 156 | case get(message, "Message") do 157 | :notfound -> 158 | true 159 | 160 | {:ok, response_text} -> 161 | !String.match?(response_text, ~r/ollow/) 162 | end 163 | end 164 | 165 | def is_event_last_for_response(%Message{} = message) do 166 | with :notfound <- get(message, "EventList"), 167 | :notfound <- get(message, "Event") do 168 | false 169 | else 170 | {:ok, response_text} -> 171 | String.match?(response_text, ~r/omplete/) 172 | 173 | _ -> 174 | false 175 | end 176 | end 177 | 178 | defp is_type(%Message{} = message, type) do 179 | case get(message, type) do 180 | {:ok, _} -> true 181 | _ -> false 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /test/message_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.MessageTest do 2 | use ExUnit.Case, async: true 3 | alias ExAmi.Message 4 | 5 | describe "setters" do 6 | test "sets all" do 7 | expected = %Message.Message{attributes: Enum.into([{"one", "two"}, {"three", "four"}], %{})} 8 | 9 | assert Message.new_message() 10 | |> Message.set_all([{"one", "two"}, {"three", "four"}]) == expected 11 | end 12 | 13 | test "sets all variables" do 14 | expected = %Message.Message{variables: Enum.into([{"one", "two"}, {"three", "four"}], %{})} 15 | 16 | assert Message.new_message() 17 | |> Message.set_all_variables([{"one", "two"}, {"three", "four"}]) == expected 18 | end 19 | end 20 | 21 | describe "marshall" do 22 | test "handles key value" do 23 | assert Message.marshall("key", "value") == "key: value\r\n" 24 | end 25 | 26 | test "handles key value acc" do 27 | assert Message.marshall("key", "value") 28 | |> Message.marshall("key2", "value2") == "key: value\r\nkey2: value2\r\n" 29 | end 30 | 31 | test "handles variable" do 32 | assert Message.marshall_variable("name", "value") == "Variable: name=value\r\n" 33 | end 34 | 35 | test "handles variable with acc" do 36 | assert Message.marshall("key", "value") 37 | |> Message.marshall_variable("name", "val") == "key: value\r\nVariable: name=val\r\n" 38 | end 39 | 40 | test "handles simple Message" do 41 | message = %Message.Message{attributes: Enum.into([{"one", "two"}], %{})} 42 | assert Message.marshall(message) == "one: two\r\n\r\n" 43 | end 44 | end 45 | 46 | describe "misc" do 47 | test "explodes" do 48 | assert Message.explode_lines("var: name\r\n") == ["var: name"] 49 | 50 | assert Message.explode_lines("var: name\r\nVariable: name=val\r\n") == [ 51 | "var: name", 52 | "Variable: name=val" 53 | ] 54 | end 55 | end 56 | 57 | describe "unmarshall" do 58 | test "handles an attribute" do 59 | expected = %Message.Message{attributes: Enum.into([{"var", "name"}], %{})} 60 | assert Message.unmarshall("var: name\r\n") == expected 61 | end 62 | 63 | test "handles 2 attributes" do 64 | expected = %Message.Message{ 65 | attributes: Enum.into([{"var", "name"}, {"var2", "name2"}], %{}) 66 | } 67 | 68 | assert Message.unmarshall("var: name\r\nvar2: name2\r\n") == expected 69 | end 70 | 71 | test "handles a success response" do 72 | message = 73 | "Response: Success\r\nActionID: 1423701662161185\r\nMessage: Authentication accepted\r\n" 74 | 75 | expected = 76 | Message.set("Response", "Success") 77 | |> Message.set("ActionID", "1423701662161185") 78 | |> Message.set("Message", "Authentication accepted") 79 | 80 | unmarshalled = Message.unmarshall(message) 81 | assert unmarshalled == expected 82 | assert Message.is_response(unmarshalled) == true 83 | end 84 | 85 | test "handles all key/value pairs" do 86 | %Message.Message{attributes: attributes} = Message.unmarshall(all_key_value_pairs()) 87 | assert attributes["Event"] == "SuccessfulAuth" 88 | assert attributes["Privilege"] == "security,all" 89 | assert attributes["SessionID"] == "0x7f0c50000910" 90 | end 91 | 92 | test "response follows ResponseData" do 93 | %Message.Message{attributes: attributes} = Message.unmarshall(response_follows_message()) 94 | assert attributes["Response"] == "Follows" 95 | assert attributes["Privilege"] == "Command" 96 | assert attributes["ActionID"] == "1530285340189277" 97 | [one, two, three, four] = attributes["ResponseData"] |> String.split("\n", trim: true) 98 | 99 | assert String.match?( 100 | one, 101 | ~r/Name\/username\s+Host\s+Dyn Forcerport Comedia ACL Port Status Description/ 102 | ) 103 | 104 | assert String.match?(two, ~r/200\s+\(Unspecified\)\s+D No\s+No\s+A 0\s+UNKNOWN/) 105 | 106 | assert three == 107 | "1 sip peers [Monitored: 0 online, 1 offline Unmonitored: 0 online, 0 offline]" 108 | 109 | assert four == "--END COMMAND--" 110 | end 111 | end 112 | 113 | describe "queries" do 114 | test "finds a response" do 115 | assert Message.set("Response", "something") |> Message.is_response() == true 116 | end 117 | 118 | test "does not find response" do 119 | assert Message.set("something", "other") |> Message.is_response() == false 120 | end 121 | 122 | test "find an event" do 123 | assert Message.set("Event", "something") |> Message.is_event() == true 124 | end 125 | 126 | test "does not find an event" do 127 | assert Message.set("something", "other") |> Message.is_event() == false 128 | end 129 | 130 | test "finds a success response" do 131 | assert Message.set("Response", "Success") |> Message.is_response_success() == true 132 | end 133 | 134 | test "does not find a success response" do 135 | assert Message.set("Response", "Failure") |> Message.is_response_success() == false 136 | end 137 | 138 | test "does not find a complete response" do 139 | assert Message.set("Message", "follows") |> Message.is_response_complete() == false 140 | end 141 | 142 | test "finds a complete response" do 143 | assert Message.set("other", "Failure") |> Message.is_response_complete() == true 144 | end 145 | 146 | test "finds a complete response again" do 147 | assert Message.set("Message", "more here") |> Message.is_response_complete() == true 148 | end 149 | 150 | test "finds last event for a response" do 151 | assert Message.set("EventList", "Complete") |> Message.is_event_last_for_response() == true 152 | end 153 | 154 | test "does not find a last event for a response" do 155 | assert Message.set("Eventlist", "something") |> Message.is_event_last_for_response() == 156 | false 157 | end 158 | 159 | test "does not find a last event for a response again" do 160 | assert Message.set("Message", "more here") |> Message.is_event_last_for_response() == false 161 | end 162 | end 163 | 164 | defp convert_newlines(text) do 165 | text 166 | |> String.split("\n", trim: true) 167 | |> Enum.join("\r\n") 168 | end 169 | 170 | defp all_key_value_pairs, 171 | do: 172 | """ 173 | Event: SuccessfulAuth 174 | Privilege: security,all 175 | EventTV: 2018-06-29T10:20:05.681-0500 176 | Severity: Informational 177 | Service: AMI 178 | EventVersion: 1 179 | AccountID: infinity_one 180 | SessionID: 0x7f0c50000910 181 | LocalAddress: IPV4/TCP/0.0.0.0/5038 182 | RemoteAddress: IPV4/TCP/10.30.50.10/42465 183 | UsingPassword: 0 184 | SessionTV: 2018-06-29T10:20:05.681-0500 185 | """ 186 | |> convert_newlines() 187 | 188 | defp response_follows_message, 189 | do: 190 | """ 191 | Response: Follows 192 | Privilege: Command 193 | ActionID: 1530285340189277 194 | Name/username Host Dyn Forcerport Comedia ACL Port Status Description 195 | 200 (Unspecified) D No No A 0 UNKNOWN 196 | 1 sip peers [Monitored: 0 online, 1 offline Unmonitored: 0 online, 0 offline] 197 | --END COMMAND-- 198 | """ 199 | |> convert_newlines() 200 | end 201 | -------------------------------------------------------------------------------- /lib/ex_ami/client.ex: -------------------------------------------------------------------------------- 1 | defmodule ExAmi.Client do 2 | use GenStateMachine, callback_mode: :state_functions 3 | use ExAmi.Logger 4 | 5 | import GenStateMachineHelpers 6 | 7 | alias ExAmi.{Message, ServerConfig} 8 | 9 | defmodule ClientState do 10 | defstruct name: "", 11 | server_info: "", 12 | listeners: [], 13 | actions: %{}, 14 | connection: nil, 15 | counter: 0, 16 | logging: false, 17 | worker_name: nil, 18 | reader: nil, 19 | online: false 20 | end 21 | 22 | ################### 23 | # API 24 | 25 | def start_link(server_name, worker_name, server_info) do 26 | do_start_link([server_name, worker_name, server_info]) 27 | end 28 | 29 | def start_link(server_name) do 30 | server_name 31 | |> get_worker_name 32 | |> GenStateMachine.call(:next_worker) 33 | |> do_start_link 34 | end 35 | 36 | defp do_start_link([_, worker_name | _] = args) do 37 | GenStateMachine.start_link(__MODULE__, args, name: worker_name) 38 | end 39 | 40 | def start_child(server_name) do 41 | # have the supervisor start the new process 42 | ExAmi.Supervisor.start_child(server_name) 43 | end 44 | 45 | def online?(pid) when is_pid(pid) do 46 | GenStateMachine.call(pid, :online) 47 | end 48 | 49 | def online?(client) do 50 | GenStateMachine.call(get_worker_name(client), :online) 51 | end 52 | 53 | def process_salutation(client, salutation) do 54 | GenStateMachine.cast(client, {:salutation, salutation}) 55 | end 56 | 57 | def process_response(client, {:response, response}) do 58 | GenStateMachine.cast(client, {:response, response}) 59 | end 60 | 61 | def process_event(client, {:event, event}) do 62 | GenStateMachine.cast(client, {:event, event}) 63 | end 64 | 65 | def socket_close(client) do 66 | Logger.debug(fn -> "socket_close client: " <> inspect(client) end) 67 | GenStateMachine.call(client, :socket_close) 68 | end 69 | 70 | def restart!(pid) when is_pid(pid) do 71 | GenStateMachine.cast(pid, :restart) 72 | end 73 | 74 | def restart!(client) do 75 | GenStateMachine.cast(get_worker_name(client), :restart) 76 | end 77 | 78 | def register_listener(pid, listener_descriptor) when is_pid(pid), 79 | do: do_register_listener(pid, listener_descriptor) 80 | 81 | def register_listener(client, listener_descriptor), 82 | do: do_register_listener(get_worker_name(client), listener_descriptor) 83 | 84 | defp do_register_listener(client, listener_descriptor), 85 | do: GenStateMachine.cast(client, {:register, listener_descriptor}) 86 | 87 | def get_worker_name(server_name) do 88 | __MODULE__ 89 | |> Module.concat(server_name) 90 | |> Module.split() 91 | |> Enum.join("_") 92 | |> String.downcase() 93 | |> String.to_atom() 94 | end 95 | 96 | def send_action(pid, action, callback) when is_pid(pid), do: _send_action(pid, action, callback) 97 | 98 | def send_action(client, action, callback), 99 | do: _send_action(get_worker_name(client), action, callback) 100 | 101 | defp _send_action(client, action, callback), 102 | do: GenStateMachine.cast(client, {:action, action, callback}) 103 | 104 | def status(pid) when is_pid(pid), do: GenStateMachine.call(pid, :status) 105 | 106 | def status(client), do: GenStateMachine.call(get_worker_name(client), :status) 107 | 108 | def stop(pid), do: GenStateMachine.cast(pid, :stop) 109 | 110 | ################### 111 | # Callbacks 112 | 113 | def init([server_name, worker_name, server_info]) do 114 | # :erlang.process_flag(:trap_exit, true) 115 | 116 | logging = ServerConfig.get(server_info, :logging) || false 117 | 118 | send(self(), {:timeout, :connecting, 0, ServerConfig.get(server_info, :connection)}) 119 | 120 | {:ok, :connecting, 121 | %ClientState{ 122 | name: server_name, 123 | server_info: server_info, 124 | worker_name: worker_name, 125 | logging: logging 126 | }} 127 | end 128 | 129 | ################### 130 | # States 131 | 132 | def connecting( 133 | _event_type, 134 | {:timeout, :connecting, cnt, {conn_module, conn_options}} = ev, 135 | data 136 | ) do 137 | case :erlang.apply(conn_module, :open, [conn_options]) do 138 | {:ok, conn} -> 139 | reader = ExAmi.Reader.start_link(data.worker_name, conn) 140 | 141 | next_state( 142 | %ClientState{data | connection: conn, reader: reader, online: true}, 143 | :wait_saluation 144 | ) 145 | 146 | _error -> 147 | Process.send_after(self(), put_elem(ev, 2, cnt + 1), connecting_timer(cnt)) 148 | next_state(%ClientState{data | online: false}, :connecting) 149 | end 150 | end 151 | 152 | def connecting(event_type, event_content, data) do 153 | handle_event(event_type, event_content, data) 154 | end 155 | 156 | def wait_saluation(:cast, {:salutation, salutation}, state) do 157 | :ok = validate_salutation(salutation) 158 | username = ServerConfig.get(state.server_info, :username) 159 | secret = ServerConfig.get(state.server_info, :secret) 160 | action = Message.new_action("Login", [{"Username", username}, {"Secret", secret}]) 161 | 162 | :ok = state.connection.send.(action) 163 | 164 | next_state(state, :wait_login_response) 165 | end 166 | 167 | def wait_saluation(event_type, event_content, data) do 168 | handle_event(event_type, event_content, data) 169 | end 170 | 171 | def wait_login_response(:cast, {:response, response}, state) do 172 | case Message.is_response_success(response) do 173 | false -> 174 | :error_logger.error_msg('Cant login: ~p', [response]) 175 | :erlang.error(:cantlogin) 176 | 177 | true -> 178 | next_state(state, :receiving) 179 | end 180 | end 181 | 182 | def wait_login_response(event_type, event_content, data) do 183 | handle_event(event_type, event_content, data) 184 | end 185 | 186 | def receiving(:cast, {:response, response}, %ClientState{actions: actions} = state) do 187 | # Logger.info "response: " <> inspect(response) 188 | pong = response.attributes["Ping"] == "Pong" 189 | 190 | if state.logging and !pong, do: Logger.debug(Message.format_log(response)) 191 | 192 | # Find the correct action information for this response 193 | {:ok, action_id} = Message.get(response, "ActionID") 194 | 195 | new_actions = 196 | case Map.fetch(actions, action_id) do 197 | {:ok, {action, :none, events, callback}} -> 198 | # See if we should dispatch this right away or wait for the events needed 199 | # to complete the response. 200 | cond do 201 | Message.is_response_error(response) -> 202 | run_callback(callback, response, events) 203 | actions 204 | 205 | Message.is_response_complete(response) -> 206 | # Complete response. Dispatch and remove the action from the queue. 207 | run_callback(callback, response, events) 208 | Map.delete(actions, action_id) 209 | 210 | true -> 211 | # Save the response so we can receive the associated events to 212 | # dispatch later. 213 | Map.put(actions, action_id, {action, response, [], callback}) 214 | end 215 | 216 | other -> 217 | Logger.warn( 218 | "Could not find action for response: #{inspect(response)}. Received #{inspect(other)}" 219 | ) 220 | 221 | actions 222 | end 223 | 224 | state 225 | |> struct(actions: new_actions) 226 | |> next_state(:receiving) 227 | end 228 | 229 | def receiving(:cast, {:event, event}, %ClientState{actions: actions} = state) do 230 | case Message.get(event, "ActionID") do 231 | :notfound -> 232 | # async event 233 | dispatch_event(state.name, event, state.listeners) 234 | next_state(state, :receiving) 235 | 236 | {:ok, action_id} -> 237 | # this one belongs to a response 238 | case Map.get(actions, action_id) do 239 | nil -> 240 | # ignore: not ours, or stale. 241 | next_state(state, :receiving) 242 | 243 | {action, response, events, callback} -> 244 | new_events = [event | events] 245 | 246 | new_actions = 247 | case Message.is_event_last_for_response(event) do 248 | false -> 249 | Map.put(actions, action_id, {action, response, new_events, callback}) 250 | 251 | true -> 252 | run_callback(callback, response, Enum.reverse(new_events)) 253 | Map.delete(state.actions, action_id) 254 | end 255 | 256 | state 257 | |> struct(actions: new_actions) 258 | |> next_state(:receiving) 259 | end 260 | end 261 | end 262 | 263 | def receiving(:cast, {:action, action, callback}, state) do 264 | {:ok, action_id} = Message.get(action, "ActionID") 265 | do_receive_action(action, action_id, Map.get(state.actions, action_id), callback, state) 266 | end 267 | 268 | def receiving(event_type, event_content, data) do 269 | handle_event(event_type, event_content, data) 270 | end 271 | 272 | def handle_event( 273 | :cast, 274 | {:register, listener_descriptor}, 275 | %ClientState{listeners: listeners} = client_state 276 | ) do 277 | # ignore duplicate entries 278 | if listener_descriptor in listeners do 279 | client_state 280 | else 281 | struct(client_state, listeners: [listener_descriptor | listeners]) 282 | end 283 | |> keep_state 284 | end 285 | 286 | def handle_event(:cast, :restart, %{reader: reader} = state) do 287 | send(reader, :stop) 288 | {:stop, :restart, state} 289 | end 290 | 291 | def handle_event(:cast, :stop, %{reader: reader} = state) do 292 | send(reader, :stop) 293 | # Give reader a chance to timeout, receive the :stop, and shutdown 294 | Process.sleep(100) 295 | ExAmi.Supervisor.stop_child(self()) 296 | {:stop, :normal, state} 297 | end 298 | 299 | def handle_event({:call, from}, :next_worker, %{name: name} = state) do 300 | next = state.counter + 1 301 | new_worker_name = String.to_atom("#{get_worker_name(name)}_#{next}") 302 | 303 | state 304 | |> struct(counter: next) 305 | |> keep_state([{:reply, from, [name, new_worker_name, state.server_info]}]) 306 | end 307 | 308 | def handle_event({:call, from}, :socket_close, state) do 309 | dispatch_event(state.name, "Shutdown", state.listeners) 310 | keep_state(state, [{:reply, from, :ok}]) 311 | end 312 | 313 | def handle_event({:call, from}, :online, state) do 314 | keep_state(state, [{:reply, from, state.online}]) 315 | end 316 | 317 | def handle_event({:call, from}, :status, state) do 318 | keep_state(state, [{:reply, from, state}]) 319 | end 320 | 321 | def handle_event(_ev, _evd, data) do 322 | keep_state(data) 323 | end 324 | 325 | ################### 326 | # Private Internal 327 | 328 | defp connecting_timer(cnt) when cnt < 5, do: 2_000 329 | defp connecting_timer(cnt) when cnt < 10, do: 10_000 330 | defp connecting_timer(cnt) when cnt < 20, do: 30_000 331 | defp connecting_timer(_), do: 60_000 332 | 333 | defp validate_salutation("Asterisk Call Manager/1.1\r\n"), do: :ok 334 | defp validate_salutation("Asterisk Call Manager/1.0\r\n"), do: :ok 335 | defp validate_salutation("Asterisk Call Manager/1.2\r\n"), do: :ok 336 | defp validate_salutation("Asterisk Call Manager/1.3\r\n"), do: :ok 337 | 338 | defp validate_salutation(saluation = "Asterisk Call Manager/2.10." <> minor) do 339 | if Regex.match?(~r/\d+\r\n/, minor) do 340 | :ok 341 | else 342 | saluation_error(saluation) 343 | end 344 | end 345 | 346 | defp validate_salutation(invalid_id) do 347 | saluation_error(invalid_id) 348 | end 349 | 350 | defp saluation_error(invalid_id) do 351 | Logger.error("Invalid Salutation #{inspect(invalid_id)}") 352 | :unknown_salutation 353 | end 354 | 355 | defp do_receive_action(action, action_id, nil, callback, state) do 356 | state = 357 | struct(state, actions: Map.put(state.actions, action_id, {action, :none, [], callback})) 358 | 359 | :ok = state.connection.send.(action) 360 | next_state(state, :receiving) 361 | end 362 | 363 | defp do_receive_action(action, action_id, old_action, callback, state) do 364 | Logger.warn( 365 | "duplicate action ID #{action_id}\nold action: #{inspect(old_action)}\nnew_action: #{inspect(action)}" 366 | ) 367 | 368 | action_id = action_id <> "_alt" 369 | action = Message.put(action, "ActionID", action_id) 370 | do_receive_action(action, action_id, Map.get(state.actions, action_id), callback, state) 371 | end 372 | 373 | defp dispatch_event(server_name, event, listeners) do 374 | Enum.each(listeners, fn 375 | {function, predicate} when predicate in [false, nil, :none] -> 376 | apply_fun(function, [server_name, event]) 377 | 378 | {function, predicate} -> 379 | case apply_fun(predicate, [event]) do 380 | true -> apply_fun(function, [server_name, event]) 381 | _ -> :ok 382 | end 383 | end) 384 | end 385 | 386 | def apply_fun({mod, fun}, args) do 387 | apply(mod, fun, args) 388 | end 389 | 390 | def apply_fun(fun, args) when is_function(fun, 1) do 391 | fun.(hd(args)) 392 | end 393 | 394 | def apply_fun(fun, args) when is_function(fun, 2) do 395 | [arg1, arg2] = args 396 | fun.(arg1, arg2) 397 | end 398 | 399 | def apply_fun(fun, args) do 400 | Logger.error("Invalid function #{inspect(fun)} with args: #{inspect(args)}") 401 | false 402 | end 403 | 404 | defp run_callback(nil, _arg1, _arg2) do 405 | :ok 406 | end 407 | 408 | defp run_callback({module, fun}, arg1, arg2) do 409 | apply(module, fun, [arg1, arg2]) 410 | end 411 | 412 | defp run_callback(callback, arg1, arg2) when is_function(callback, 2) do 413 | callback.(arg1, arg2) 414 | end 415 | end 416 | --------------------------------------------------------------------------------