lib/glific/providers/gupshup/message.ex

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

  @behaviour Glific.Providers.MessageBehaviour

  alias Glific.{
    Communications,
    Messages.Message,
    Partners,
    Repo
  }

  import Ecto.Query, warn: false
  require Logger

  @channel "whatsapp"

  @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
    %{type: :text, text: message.body, isHSM: message.is_hsm}
    |> check_size()
    |> 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
    message_media = message.media

    %{
      type: :image,
      originalUrl: message_media.source_url,
      previewUrl: message_media.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
    message_media = message.media

    %{
      type: :audio,
      url: message_media.source_url
    }
    |> 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
    message_media = message.media

    %{
      type: :video,
      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
    message_media = message.media

    %{
      type: :file,
      url: message_media.source_url,
      filename: message_media.caption
    }
    |> 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

  @doc false
  @spec send_interactive(Message.t(), map()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def send_interactive(message, attrs \\ %{}) do
    message.interactive_content
    |> Map.merge(%{type: message.type})
    |> send_message(message, attrs)
  end

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

  @spec context_id(map()) :: String.t() | nil
  defp context_id(payload),
    do: get_in(payload, ["context", "gsId"]) || get_in(payload, ["context", "id"])

  @doc false
  @spec receive_text(payload :: map()) :: map()
  def receive_text(params) do
    payload = params["payload"]
    message_payload = payload["payload"]

    # 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 payload["sender"]["phone"] in [nil, ""] do
      error = "Phone number is blank, #{inspect(payload)}"
      Glific.log_error(error)
      raise(RuntimeError, message: error)
    end

    %{
      bsp_message_id: payload["id"],
      context_id: context_id(payload),
      body: message_payload["text"],
      sender: %{
        phone: payload["sender"]["phone"],
        name: payload["sender"]["name"]
      }
    }
  end

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

    %{
      bsp_message_id: payload["id"],
      context_id: context_id(payload),
      caption: message_payload["caption"],
      url: message_payload["url"],
      source_url: message_payload["url"],
      sender: %{
        phone: payload["sender"]["phone"],
        name: payload["sender"]["name"]
      }
    }
  end

  @doc false
  @spec receive_location(map()) :: map()
  def receive_location(params) do
    payload = params["payload"]
    message_payload = payload["payload"]

    %{
      bsp_message_id: payload["id"],
      context_id: context_id(payload),
      longitude: message_payload["longitude"],
      latitude: message_payload["latitude"],
      sender: %{
        phone: payload["sender"]["phone"],
        name: payload["sender"]["name"]
      }
    }
  end

  @doc false
  @spec receive_billing_event(map()) :: {:ok, map()} | {:error, String.t()}
  def receive_billing_event(params) do
    references = get_in(params, ["payload", "references"])
    deductions = get_in(params, ["payload", "deductions"])
    bsp_message_id = references["gsId"] || references["id"]

    message_id =
      Repo.fetch_by(Message, %{
        bsp_message_id: bsp_message_id
      })
      |> case do
        {:ok, message} -> message.id
        {:error, _error} -> nil
      end

    message_conversation = %{
      deduction_type: deductions["type"],
      is_billable: deductions["billable"],
      conversation_id: references["conversationId"],
      payload: params,
      message_id: message_id
    }

    {:ok, message_conversation}
  end

  @doc false
  @spec receive_interactive(map()) :: map()
  def receive_interactive(params) do
    payload = params["payload"]
    message_payload = payload["payload"]

    ## Gupshup does not send an option id back as a response.
    ## They just send the postbackText back as the option id.
    ## formatting that here will help us to keep that consistent.
    ## We might remove this in the future when gupshup will start sending the option id.

    interactive_content = message_payload |> Map.merge(%{"id" => message_payload["postbackText"]})

    %{
      bsp_message_id: payload["id"],
      context_id: context_id(payload),
      body: message_payload["title"],
      interactive_content: interactive_content,
      sender: %{
        phone: payload["sender"]["phone"],
        name: payload["sender"]["name"]
      }
    }
  end

  @doc false
  @spec format_sender(Message.t()) :: map()
  defp format_sender(message) do
    organization = Partners.organization(message.organization_id)

    %{
      "source" => message.sender.phone,
      "src.name" => organization.services["bsp"].secrets["app_name"]
    }
  end

  @max_size 4096
  @doc false
  @spec check_size(map()) :: map()
  defp check_size(%{text: 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

  @doc false
  @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, attrs) do
    request_body =
      %{"channel" => @channel}
      |> Map.merge(format_sender(message))
      |> Map.put(:destination, message.receiver.phone)
      |> Map.put("message", Jason.encode!(payload))

    ## 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

    create_oban_job(message, request_body, attrs)
  end

  @doc false
  @spec to_minimal_map(map()) :: map()
  defp to_minimal_map(attrs) do
    Map.take(attrs, [:params, :template_id, :template_uuid, :is_hsm, :template_type])
  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