lib/glific/providers/gupshup_enterprise/message.ex

defmodule Glific.Providers.Gupshup.Enterprise.Message do
  @moduledoc """
  Message API layer between application and Gupshup
  """

  @behaviour Glific.Providers.MessageBehaviour

  alias Glific.{
    Communications,
    Messages.Message
  }

  require Logger

  @doc false
  @spec send_text(Message.t(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()}
  def send_text(message, attrs \\ %{}) do
    %{msg_type: :DATA_TEXT, msg: message.body}
    |> check_size()
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_video(Message.t(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()}
  def send_video(message, attrs \\ %{}) do
    %{
      msg_type: :VIDEO,
      media_url: message.media.source_url,
      caption: caption(message.media.caption)
    }
    |> check_size()
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_audio(Message.t(), map()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def send_audio(message, attrs \\ %{}) do
    %{
      msg_type: :AUDIO,
      media_url: message.media.source_url
    }
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_image(Message.t(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()}
  def send_image(message, attrs \\ %{}) do
    %{
      msg_type: :IMAGE,
      media_url: message.media.source_url,
      caption: caption(message.media.caption)
    }
    |> check_size()
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_document(Message.t(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def send_document(message, attrs \\ %{}) do
    %{
      msg_type: :DOCUMENT,
      media_url: message.media.source_url,
      caption: message.media.caption
    }
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_interactive(Message.t(), map()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def send_interactive(message, attrs) do
    interactive_content = parse_interactive_message(attrs.interactive_content, message.type)

    interactive_media_type =
      get_in(attrs, [:interactive_content, "content", "type"])
      |> then(&if &1 == "file", do: "document", else: &1)

    %{
      interactive_content: interactive_content,
      msg: get_in(attrs, [:interactive_content, "content", "text"]) || message.body,
      interactive_type: message.type,
      media_url: get_in(attrs, [:interactive_content, "content", "url"]),
      interactive_media_type: interactive_media_type
    }
    |> send_message(message, attrs)
  end

  @doc false
  @spec send_sticker(Message.t(), map()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def send_sticker(message, attrs \\ %{}) do
    message_media = message.media

    %{
      type: :sticker,
      url: message_media.url
    }
    |> send_message(message, attrs)
  end

  @spec parse_interactive_message(map(), atom()) :: map()
  defp parse_interactive_message(interactive_content, :quick_reply),
    do: %{"buttons" => parse_buttons(interactive_content["options"])}

  defp parse_interactive_message(interactive_content, :list) do
    %{
      "button" => interactive_content["globalButtons"] |> List.first() |> Map.get("title"),
      "sections" => parse_section(interactive_content["items"])
    }
  end

  @spec parse_buttons(list()) :: list()
  defp parse_buttons(interactive_content) do
    Enum.reduce(interactive_content, [], fn button, acc ->
      acc ++
        [
          %{
            "type" => "reply",
            "reply" => %{"id" => Ecto.UUID.generate(), "title" => button["title"]}
          }
        ]
    end)
  end

  @spec parse_section(list()) :: list()
  defp parse_section(items),
    do: Enum.reduce(items, [], fn item, acc -> acc ++ do_parse_section(item) end)

  @spec do_parse_section(list()) :: list()
  defp do_parse_section(item),
    do: [%{"title" => item["title"], "rows" => parse_rows(item["options"])}]

  @spec parse_rows(list()) :: list()
  defp parse_rows(rows) do
    Enum.reduce(rows, [], fn row, acc ->
      acc ++
        [
          %{
            # generating uuid for id as we don't actually use id
            "id" => Ecto.UUID.generate(),
            "title" => row["title"],
            "description" => row["description"]
          }
        ]
    end)
  end

  @spec caption(nil | String.t()) :: String.t()
  defp caption(nil), do: ""
  defp caption(caption), do: caption

  @max_size 4096

  @spec check_size(map()) :: map()
  defp check_size(%{msg: text} = attrs) do
    if String.length(text) < @max_size,
      do: attrs,
      else: attrs |> Map.merge(%{error: "Message size greater than #{@max_size} characters"})
  end

  defp check_size(%{caption: caption} = attrs) do
    if String.length(caption) < @max_size,
      do: attrs,
      else: attrs |> Map.merge(%{error: "Message size greater than #{@max_size} characters"})
  end

  @spec send_message(map(), Message.t(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()}
  defp send_message(%{error: error} = _payload, _message, _attrs), do: {:error, error}

  defp send_message(payload, message, %{has_buttons: true, parsed_body: parsed_body} = attrs) do
    encoded_message =
      payload
      |> Map.put(:msg, parsed_body)
      |> Jason.encode!()

    %{"send_to" => message.receiver.phone, "message" => encoded_message}
    |> then(&create_oban_job(message, &1, attrs))
  end

  defp send_message(payload, message, attrs) do
    ## gupshup does not allow null in the caption.
    attrs =
      if Map.has_key?(attrs, :caption) and is_nil(attrs[:caption]),
        do: Map.put(attrs, :caption, ""),
        else: attrs

    %{"send_to" => message.receiver.phone, "message" => Jason.encode!(payload)}
    |> then(&create_oban_job(message, &1, attrs))
  end

  @doc false
  @spec receive_text(map()) :: map()
  def receive_text(params) do
    # lets ensure that we have a phone number
    # sometime the gupshup payload has a blank payload
    # or maybe a simulator or some test code
    if is_nil(params["mobile"]) ||
         String.trim(params["mobile"]) == "" do
      error = "Phone number is blank, #{inspect(params)}"
      Logger.error(error)

      stacktrace =
        self()
        |> Process.info(:current_stacktrace)
        |> elem(1)

      Appsignal.send_error(:error, error, stacktrace)
      raise(RuntimeError, message: error)
    end

    %{
      bsp_message_id: params["replyId"],
      context_id: parse_context_id(params),
      body: params["text"],
      sender: %{
        phone: params["mobile"],
        name: params["name"]
      }
    }
  end

  @spec parse_context_id(map()) :: String.t()
  defp parse_context_id(params) do
    reply_id = Map.get(params, "replyId")
    message_id = Map.get(params, "messageId")

    if is_nil(reply_id) && is_nil(message_id),
      do: "",
      else: reply_id <> "-" <> message_id
  end

  @doc false
  @spec receive_media(map()) :: map()
  def receive_media(params) do
    message_payload = get_message_payload(params["type"], params)

    %{
      bsp_message_id: params["replyId"],
      context_id: params["messageId"],
      caption: message_payload["caption"],
      url: message_payload["url"] <> message_payload["signature"],
      source_url: message_payload["url"] <> message_payload["signature"],
      sender: %{
        phone: params["mobile"],
        name: params["name"]
      }
    }
  end

  defp get_message_payload("image", params), do: Jason.decode!(params["image"])
  defp get_message_payload("video", params), do: Jason.decode!(params["video"])
  defp get_message_payload("audio", params), do: Jason.decode!(params["audio"])
  defp get_message_payload("voice", params), do: Jason.decode!(params["voice"])
  defp get_message_payload("document", params), do: Jason.decode!(params["document"])

  @doc false
  @spec receive_location(map()) :: map()
  def receive_location(params) do
    location = Jason.decode!(params["location"])

    %{
      bsp_message_id: params["replyId"],
      context_id: params["messageId"],
      longitude: location["longitude"],
      latitude: location["latitude"],
      sender: %{
        phone: params["mobile"],
        name: params["name"]
      }
    }
  end

  @doc false
  @spec receive_billing_event(map()) :: {:ok, map()} | {:error, String.t()}
  def receive_billing_event(_params) do
    {:ok, %{}}
  end

  @doc false
  @spec receive_interactive(map()) :: map()
  def receive_interactive(_params), do: %{}

  @spec to_minimal_map(map()) :: map()
  defp to_minimal_map(attrs) do
    Map.take(attrs, [:params, :template_id, :template_uuid, :is_hsm, :template_type, :has_buttons])
  end

  @spec create_oban_job(Message.t(), map(), map()) ::
          {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  defp create_oban_job(message, request_body, attrs) do
    attrs = to_minimal_map(attrs)
    worker_module = Communications.provider_worker(message.organization_id)
    worker_args = %{message: Message.to_minimal_map(message), payload: request_body, attrs: attrs}

    worker_module.new(worker_args, scheduled_at: message.send_at)
    |> Oban.insert()
  end
end