lib/botlead/bot/adapter/telegram.ex

defmodule Botlead.Bot.Adapter.Telegram do
  @moduledoc """
  Implementation business logic for Bot server for Telegram platform.
  """

  require Logger

  @type cmd :: {:relay_msg_to_client, String.t(), map()} | {:restart_client, String.t()}
  @type parsed_message :: :no_parser | :invalid_message | cmd

  @retry_delay 500

  @doc """
  Start Telegram process either by webhook or with periodical config.
  """
  @spec init() :: :ok | {:poll, integer(), integer()}
  def init do
    if config(:use_webhook, false) do
      webhook = config!(:use_webhook)
      Logger.info(fn -> "Setting Telegram postback webhook to: #{webhook}" end)
      Nadia.set_webhook(url: webhook)
      :ok
    else
      poll_delay = config!(:poll_delay)
      poll_limit = config!(:poll_limit)
      {:poll, poll_delay, poll_limit}
    end
  end

  @doc """
  Deliver message to Telegram with successful delivery postback.
  """
  @spec send_message(String.t() | integer(), String.t(), pid() | nil, Keyword.t()) :: :ok
  def send_message(chat_id, text, client_pid, opts) do
    with false <- config(:sendbox_message_send, false),
         {:ok, result} <- Nadia.send_message(chat_id, text, opts) do
      maybe_notify_msg_result(client_pid, {:sent, result})
    else
      true ->
        Logger.info(fn -> "No chat messages sent in a sandbox mode!" end)
        maybe_notify_msg_result(client_pid, {:sent, :ok})

      {:error, %Nadia.Model.Error{reason: "Please wait a little"}} ->
        Logger.warn(fn -> "Telegram bot message retry send_message!" end)
        :timer.sleep(@retry_delay)
        send_message(chat_id, text, client_pid, opts)

      response ->
        Logger.warn(fn -> "Unexpected response from Nadia client #{inspect(response)}" end)
        :ok
    end
  end

  @doc """
  Edit existing message by it's id.
  """
  @spec edit_message(String.t(), String.t(), String.t(), pid() | nil, Keyword.t()) :: :ok
  def edit_message(chat_id, message_id, text, client_pid, opts) do
    unless config(:sendbox_message_send, false) do
      case Nadia.edit_message_text(chat_id, message_id, nil, text, opts) do
        {:ok, result} ->
          maybe_notify_msg_result(client_pid, {:edited, result})

        {:error, %Nadia.Model.Error{reason: "Please wait a little"}} ->
          Logger.warn(fn -> "Telegram bot message retry edit_message!" end)
          :timer.sleep(@retry_delay)
          edit_message(chat_id, message_id, text, client_pid, opts)
      end
    else
      Logger.info(fn -> "No chat messages are edited in a sandbox mode!" end)
      maybe_notify_msg_result(client_pid, {:edited, message_id})
    end
  end

  @doc """
  Delete existing message by it's id.
  """
  @spec delete_message(String.t(), String.t(), pid() | nil, Keyword.t()) :: :ok
  def delete_message(chat_id, message_id, client_pid, opts) do
    unless config(:sendbox_message_send, false) do
      case Nadia.API.request("deleteMessage", chat_id: chat_id, message_id: message_id) do
        :ok ->
          maybe_notify_msg_result(client_pid, {:deleted, message_id})

        {:error, %Nadia.Model.Error{reason: "Please wait a little"}} ->
          Logger.warn(fn -> "Telegram bot message retry delete_message!" end)
          :timer.sleep(@retry_delay)
          delete_message(chat_id, message_id, client_pid, opts)
      end
    else
      Logger.info(fn -> "No chat messages are deleted in a sandbox mode!" end)
      maybe_notify_msg_result(client_pid, {:deleted, message_id})
    end
  end

  @doc """
  Poll all updates from Telegram server.
  """
  @spec get_updates(integer(), integer()) :: {:ok, [map()]} | :error
  def get_updates(last_update, poll_limit) do
    opts = [limit: poll_limit]
    opts = if last_update > 0, do: Keyword.put(opts, :offset, last_update), else: opts

    case Nadia.get_updates(opts) do
      {:ok, messages} ->
        {:ok, messages}

      issues ->
        Logger.warn(fn -> "Telegram polling issues #{inspect(issues)}" end)
        :error
    end
  end

  @doc """
  Parse messages recieved from Telegram server.
  """
  @spec process_messages([map()], [integer()]) :: {:ok, [integer()], integer(), [parsed_message]}
  def process_messages(messages, old_message_ids) do
    {new_updates, cmds} =
      messages
      |> Enum.filter(&(&1.update_id not in old_message_ids))
      |> Enum.map(&parse_message/1)
      |> Enum.unzip()

    last_update = Enum.max(new_updates, fn -> nil end)
    {:ok, new_updates, last_update, cmds}
  end

  @doc """
  Create Nadia client option specification for message response.
  """
  @spec msg_to_opts(%Botlead.Message{}, Keyword.t()) :: Keyword.t()
  def msg_to_opts(%Botlead.Message{} = msg, msg_opts \\ []) do
    Enum.reduce(Map.from_struct(msg), msg_opts, fn {key, value}, msg_opts ->
      case {key, value} do
        {:parse_mode, value} when value != nil ->
          Keyword.put(msg_opts, :parse_mode, value)

        {:inline_keyboard, value} when value != nil ->
          Keyword.put(msg_opts, :reply_markup, %{inline_keyboard: value})

        _ ->
          msg_opts
      end
    end)
  end

  @doc """
  Read module config value with default as fallback.
  """
  @spec config(atom(), any()) :: any()
  def config(key, default \\ nil) do
    Application.get_env(:botlead, __MODULE__)
    |> Keyword.get(key, default)
  end

  @doc """
  Read module config value, raise if not configured.
  """
  @spec config!(atom()) :: any() | no_return
  def config!(key) do
    Application.get_env(:botlead, __MODULE__)
    |> Keyword.fetch!(key)
  end

  # Start Telegram client if needed
  @spec parse_message(map()) :: {integer(), parsed_message}
  defp parse_message(%Nadia.Model.Update{
         message: %{chat: %{id: chat_id}, text: "/restart"},
         update_id: update_id
       }) do
    {update_id, {:restart_client, "#{chat_id}", []}}
  end

  defp parse_message(
         %Nadia.Model.Update{message: %{chat: %{id: chat_id}}, update_id: update_id} = message
       ) do
    {update_id, {:relay_msg_to_client, "#{chat_id}", message}}
  end

  defp parse_message(
         %Nadia.Model.Update{
           callback_query: %{message: %{chat: %{id: chat_id}}},
           update_id: update_id
         } = message
       ) do
    {update_id, {:relay_msg_to_client, "#{chat_id}", message}}
  end

  defp parse_message(%{update_id: update_id} = message) when is_integer(update_id) do
    Logger.info(fn -> "Ignore message: #{inspect(message)}" end)
    {update_id, :no_parser}
  end

  defp parse_message(message) do
    Logger.warn(fn -> "Invalid message: #{inspect(message)}" end)
    {0, :invalid_message}
  end

  @spec maybe_notify_msg_result(pid() | nil, {Botlead.Client.Behaviour.delivery_action(), any()}) ::
          :ok
  defp maybe_notify_msg_result(pid, {action, result}) when is_pid(pid) do
    GenServer.cast(pid, {:message_delivery_result, action, result})
  end

  defp maybe_notify_msg_result(_, _), do: :ok
end