lib/ami/client.ex

defmodule AMI.Client do
  @moduledoc """
  Client module to connect Asterisk server and login AMI.

  Module fits to be used in a supervisor tree.

  When connection is lost in case of network problems it will
  keep trying to re-connect again every 3 seconds.

  ## Example using with supervisor

  ```
    children = [
      %{
        id: :pbx01,
        start: {
          AMI.Client,
          :start_link,
          [{'pbx01.myphones.com', 5038, "admin", "secret4", MyAMImodule}]
        }
      },
      %{
        id: :pbx02,
        start: {
          AMI.Client,
          :start_link,
          [{'127.0.0.1', 5038, "admin", "secret9", MyAMImodule}]
        }
      },
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  ```
  """
  require Logger
  use GenServer

  @timeout 3000
  @table_name :amiex_socks_table

  @doc """
  Starts a AMI client linked to the current process.

  Can be userd to start the Client in a supervisor tree.

  ## Options

  * `host` - remote Asterisk host or IP to connect

  * `port` - remote AMI port

  * `user` - AMI username as string

  * `passwd` - AMI password as string

  * `module` - elixir module that uses AMI and which will receive all incoming events.
  See exmple in AMI module

  ## Example
  ```
    AMI.Client.start_link({'localhost', 5447, "admin",  "5ecR37", MyModule})
  ```
  """
  def start_link({host, port, user, passwd, module}) do
    case :ets.whereis(@table_name) do
      :undefined -> :ets.new(@table_name, [:named_table, :public])
      _ -> nil
    end

    GenServer.start_link(
      __MODULE__,
      [host, port, user, passwd, module],
      name: pname(host, port)
    )
  end

  @doc """
  Send AMI Action packet via established connection by client
  identified by addr
  """
  @spec send(action :: AMI.Action.t(), addr :: String.t()) :: :ok
  def send(action, addr) do
    Logger.debug("Sending action to #{addr}")
    GenServer.cast(pname(addr), {:send, action, addr})
  end

  @doc """
  Send AMI Action to all established connections via clients
  """
  @spec broadcast(action :: AMI.Action.t()) :: :ok
  def broadcast(action) do
    broadcast(:ets.tab2list(@table_name), action)
  end

  @impl true
  def init([host, port, user, pass, module]) do
    Process.send(self(), :connect, [])

    {:ok, %{host: host, port: port, user: user, secret: pass, module: module, packet: ""}}
  end

  @impl true
  def handle_cast({:send, action, addr}, state) do
    Logger.debug("Cast action to #{addr}")

    case :ets.lookup(@table_name, addr) do
      [{^addr, sock}] ->
        sendto(sock, AMI.Action.to_string(action))

      [] ->
        Logger.error("Failed to find socket for '#{addr}' to send action")
    end

    {:noreply, state}
  end

  @impl true
  def handle_info({:tcp, _pid, 'Asterisk Call Manager/' ++ _}, state) do
    Logger.info("Received AMI prompt line")
    Process.send(self(), :login, [])
    {:noreply, state}
  end

  @impl true
  def handle_info(
        {:tcp, _pid, msg},
        %{packet: packet, host: host, port: port, module: mod} = state
      ) do
    packet =
      case msg do
        '\r\n' ->
          parse_msg(packet, mod, to_key(host, port))

        line ->
          packet <> to_string(line)
      end

    {:noreply, %{state | packet: packet}}
  end

  @impl true
  def handle_info({:tcp_closed, _pid}, state) do
    Logger.warn("Connection closed")
    {:noreply, connect(state)}
  end

  @impl true
  def handle_info(:connect, %{host: host, port: port} = state) do
    Logger.info("Connecting to #{host}:#{port}")
    {:noreply, connect(state)}
  end

  @impl true
  def handle_info(:login, %{sock: sock, user: user, secret: pwd} = state) do
    Logger.info("Login to AMI server with user '#{user}'")

    sendto(sock, AMI.Action.login(user, pwd))

    {:noreply, state}
  end

  defp connect(%{host: host, port: port} = state) do
    case tcp_connect(host, port) do
      {:ok, sock} ->
        :ets.insert(@table_name, {to_key(host, port), sock})
        Map.put(state, :sock, sock)

      {:error, reason} ->
        Logger.error("Failed to connect #{host}:#{port} - '#{reason}'")
        Process.send_after(self(), :connect, @timeout)
        Logger.warn("Will retry in #{@timeout}ms...")
        state
    end
  end

  defp tcp_connect(host, port) do
    opts = [active: true, send_timeout: @timeout, packet: :line]

    case :gen_tcp.connect(host, port, opts) do
      {:ok, sock} ->
        Logger.info("Connected to #{host}:#{port}")
        {:ok, sock}

      {:error, err} ->
        {:error, err}
    end
  end

  defp parse_msg(packet, mod, key) do
    case AMI.Packet.parse(packet) do
      {:ok, event} -> mod.handle_message(event, key)
      {:error, reason} -> Logger.warn("Failed to parse packet: #{reason}")
    end

    # return empty line in both cases to reset packet
    ""
  end

  defp sendto(sock, action) do
    case :gen_tcp.send(sock, action) do
      :ok -> Logger.debug("Successfully send AMI action")
      {:error, reason} -> Logger.error("Failed to send AMI packet: #{reason}")
    end
  end

  defp broadcast([{addr, _} | tail], action) do
    Logger.debug("Broadcast to #{addr}")
    __MODULE__.send(action, addr)
    broadcast(tail, action)
  end

  defp broadcast([], _action) do
    Logger.debug("Broadcast done")
    :ok
  end

  defp to_key(host, port), do: "#{host}:#{port}"
  defp pname(host, port), do: {:global, {__MODULE__, to_key(host, port)}}
  defp pname(addr), do: {:global, {__MODULE__, addr}}
end