lib/glific/flows/contact_action.ex

defmodule Glific.Flows.ContactAction do
  @moduledoc """
  Since many of the functions, also do a few actions like send a message etc
  centralizing it here
  """

  alias Glific.{
    Contacts,
    Flows,
    Flows.Node,
    Messages,
    Messages.Message,
    Repo,
    Templates.InteractiveTemplate,
    Templates.InteractiveTemplates,
    Templates.SessionTemplate
  }

  alias Glific.Flows.{Action, FlowContext, Localization, MessageVarParser}

  require Logger
  @min_delay 2
  @max_loop_limit 3
  @abort_loop_limit 4

  @doc """
  This is just a think wrapper for send_message, since its basically the same,
  but instead of sending the message to the contact, sends it to another contact
  that is identified in the action. You can send the same notification to multiple
  contacts
  """
  @spec send_broadcast(FlowContext.t(), Action.t(), [Message.t()]) :: {:ok, map(), any()}
  def send_broadcast(context, action, messages) do
    # note that we return the result of the last reduce
    # all the ones in between are ignored
    action.contacts
    |> Enum.reduce(
      {:ok, context, messages},
      fn contact, {_, _, _} ->
        {:ok, cid} = Glific.parse_maybe_integer(contact["uuid"])
        cid = Glific.Clients.broadcast(action, context.contact, cid)
        send_message(context, action, messages, cid)
      end
    )
  end

  @doc """
  Send interactive messages
  """
  @spec send_interactive_message(FlowContext.t(), Action.t(), [Message.t()]) ::
          {:ok, map(), any()}
  def send_interactive_message(context, action, messages) do
    ## We might need to think how to send the interactive message to a group
    {context, action} = process_labels(context, action)
    {cid, message_vars} = resolve_cid(context, nil)
    interactive_template_id = get_interactive_template_id(action, context)

    {:ok, interactive_template} =
      Repo.fetch_by(
        InteractiveTemplate,
        %{id: interactive_template_id, organization_id: context.organization_id}
      )

    {interactive_content, body, media_id} =
      interactive_template
      |> InteractiveTemplates.formatted_data(context.contact.language_id)

    params_count = get_params_count(action.params_count, message_vars)

    interactive_content =
      if params_count > 0 do
        params =
          Enum.map(action.params, fn
            param when is_map(param) -> MessageVarParser.parse_map(param, message_vars)
            param -> MessageVarParser.parse(param, message_vars)
          end)

        InteractiveTemplates.process_dynamic_interactive_content(
          interactive_content,
          Enum.take(params, params_count),
          %{
            type: MessageVarParser.parse(action.attachment_type, message_vars),
            url: MessageVarParser.parse(action.attachment_url, message_vars)
          }
        )
      else
        interactive_content
      end

    ## since we have flow context here, we have to replace parse the results as well.
    interactive_content = MessageVarParser.parse_map(interactive_content, message_vars)

    with {false, context} <- has_loops?(context, body, messages) do
      attrs = %{
        body: body,
        uuid: action.node_uuid,
        type: interactive_content["type"],
        receiver_id: cid,
        flow_label: action.labels,
        organization_id: context.organization_id,
        flow_id: context.flow_id,
        message_broadcast_id: context.message_broadcast_id,
        send_at: DateTime.add(DateTime.utc_now(), max(context.delay, action.delay)),
        is_optin_flow: Flows.is_optin_flow?(context.flow),
        interactive_template_id: interactive_template_id,
        interactive_content: interactive_content,
        media_id: media_id
      }

      attrs
      |> Messages.create_and_send_message()
      |> handle_message_result(context, messages, attrs)
    end
  end

  @spec has_loops?(FlowContext.t(), String.t(), [Message.t()]) ::
          {:ok, map(), any()} | {false, FlowContext.t()}
  defp has_loops?(context, body, messages) do
    {context, count} = check_recent_outbound_count(context, body)

    if count <= @max_loop_limit,
      do: {false, context},
      else: process_loops(context, count, messages, body)
  end

  # handle the case if we are sending a notification to another contact who is
  # staff, so we need info for both
  # the nil case is the regular case of sending a message
  @spec resolve_cid(FlowContext.t(), non_neg_integer | nil) :: tuple()
  defp resolve_cid(context, nil = _cid) do
    # Since we are saving the data after loading the flow
    # so we have to fetch the latest contact fields
    {
      context.contact_id,
      FlowContext.get_vars_to_parse(context)
    }
  end

  defp resolve_cid(context, cid),
    do: {
      cid,
      %{
        "contact" => Contacts.get_contact_field_map(context.contact_id),
        "staff" => Contacts.get_contact_field_map(cid),
        "results" => context.results
      }
    }

  @spec check_recent_outbound_count(FlowContext.t(), String.t()) ::
          {FlowContext.t(), non_neg_integer}
  defp check_recent_outbound_count(context, body) do
    # we'll mark that we came here and are planning to send it, even if
    # we don't end up sending it. This allows us to detect and abort infinite loops

    # count the number of times we sent the same message in the recent list
    # in the past 6 hours
    count = FlowContext.match_outbound(context, body)
    {context, count}
  end

  @spec get_body(SessionTemplate.t()) :: String.t()
  defp get_body(%{body: body} = session_template) when is_nil(body) or body == "" do
    "Session Template: " <> session_template.label
  end

  defp get_body(%{body: body}), do: body

  @doc """
  If the template is not defined for the message send text messages.
  Given a shortcode and a context, send the right session template message
  to the contact.

  We also need to handle translations for template messages, since whatsapp
  gives them unique uuids
  """
  @spec send_message(FlowContext.t(), Action.t(), [Message.t()], non_neg_integer | nil) ::
          {:ok, map(), any()}
  def send_message(context, action, messages, cid \\ nil)

  def send_message(context, %Action{templating: nil} = action, messages, cid) do
    {context, action} = process_labels(context, action)
    {cid, message_vars} = resolve_cid(context, cid)

    # get the text translation if needed
    text = Localization.get_translation(context, action, :text)

    body =
      text
      |> MessageVarParser.parse(message_vars)

    with {false, context} <- has_loops?(context, body, messages) do
      do_send_message(context, action, messages, %{
        cid: cid,
        body: body,
        text: text,
        flow_label: action.labels
      })
    end
  end

  def send_message(
        context,
        %Action{templating: templating} = action,
        messages,
        cid
      ) do
    {context, action} = process_labels(context, action)
    {cid, message_vars} = resolve_cid(context, cid)

    variables = Localization.get_translated_template_vars(context, templating)
    vars = Enum.map(variables, &MessageVarParser.parse(&1, message_vars))

    session_template = Messages.parse_template_vars(templating.template, vars)

    body = get_body(session_template)

    with {false, context} <- has_loops?(context, body, messages) do
      do_send_template_message(context, action, messages, %{
        cid: cid,
        session_template: session_template,
        params: vars,
        flow_label: action.labels
      })
    end
  end

  @spec do_send_template_message(FlowContext.t(), Action.t(), [Message.t()], map()) ::
          {:ok, map(), any()}
  defp do_send_template_message(context, action, messages, %{
         cid: cid,
         session_template: session_template,
         params: params,
         flow_label: flow_label
       }) do
    attachments = Localization.get_translation(context, action, :attachments)

    {type, media_id} =
      if is_nil(attachments) or attachments == %{},
        do: {session_template.type, session_template.message_media_id},
        else: get_media_from_attachment(attachments, "", context, cid)

    session_template =
      session_template
      |> Map.merge(%{message_media_id: media_id, type: type})

    ## This is bit expansive and we will optimize it bit more
    # session_template =
    if Flows.is_media_type?(type) and media_id != nil do
      Messages.get_message_media!(media_id)
      |> Messages.update_message_media(%{caption: session_template.body})
    end

    attrs = %{
      receiver_id: cid,
      uuid: action.node_uuid,
      flow_id: context.flow_id,
      message_broadcast_id: context.message_broadcast_id,
      is_hsm: true,
      flow_label: flow_label,
      send_at: DateTime.add(DateTime.utc_now(), max(context.delay, action.delay)),
      params: params
    }

    Messages.create_and_send_session_template(session_template, attrs)
    |> handle_message_result(context, messages, attrs)
  end

  @spec process_labels(FlowContext.t(), Action.t()) :: {FlowContext.t(), Action.t()}
  defp process_labels(context, %{labels: nil} = action), do: {context, action}

  defp process_labels(context, %{labels: labels} = action) do
    flow_label =
      labels
      |> Enum.map_join(", ", fn label ->
        FlowContext.parse_context_string(context, label["name"])
      end)

    {context, Map.put(action, :labels, flow_label)}
  end

  @spec process_loops(FlowContext.t(), non_neg_integer, [Message.t()], String.t()) ::
          {:ok, map(), any()}
  defp process_loops(context, count, messages, body) do
    if count > @abort_loop_limit do
      # this might happen when there is no Exit pathway out of the loop
      Node.infinite_loop(context, body)
    else
      # :loop_detected
      FlowContext.notification(context, "Infinite loop detected, body: #{body}. Aborting flow.")
      exit_loop(context, messages)
    end
  end

  @spec exit_loop(FlowContext.t(), [Message.t()]) ::
          {:ok, map(), any()}
  defp exit_loop(context, messages) do
    {:ok, context,
     [Messages.create_temp_message(context.organization_id, "Exit Loop") | messages]}
  end

  @spec do_send_message(FlowContext.t(), Action.t(), [Message.t()], map()) ::
          {:ok, map(), any()}
  defp do_send_message(
         context,
         action,
         messages,
         %{
           body: body,
           text: text,
           cid: cid,
           flow_label: flow_label
         }
       ) do
    organization_id = context.organization_id

    attachments = Localization.get_translation(context, action, :attachments)

    {type, media_id} = get_media_from_attachment(attachments, text, context, cid)

    attrs = %{
      uuid: action.node_uuid,
      body: body,
      type: type,
      media_id: media_id,
      receiver_id: cid,
      organization_id: organization_id,
      flow_label: flow_label,
      flow_id: context.flow_id,
      message_broadcast_id: context.message_broadcast_id,
      send_at: DateTime.add(DateTime.utc_now(), max(context.delay, action.delay)),
      is_optin_flow: Flows.is_optin_flow?(context.flow)
    }

    attrs
    |> Messages.create_and_send_message()
    |> handle_message_result(context, messages, attrs)
  end

  defp handle_message_result(result, context, messages, attrs) do
    case result do
      {:ok, message} ->
        context = FlowContext.update_recent(context, message, :recent_outbound)
        {:ok, %{context | delay: context.delay + @min_delay}, messages}

      {:error, error} ->
        error(context, error, attrs)
    end
  end

  @spec error(FlowContext.t(), any(), map()) :: {:ok, map(), any()}
  defp error(context, error, attrs) do
    message = "Error sending message, resetting context: #{inspect(error)}, #{inspect(attrs)}"

    # returning for now, but resetting the context
    context = FlowContext.reset_all_contexts(context, message)
    {:ok, context, []}
  end

  @spec get_media_from_attachment(any(), any(), FlowContext.t(), non_neg_integer()) :: any()
  defp get_media_from_attachment(attachment, _, _, _)
       when attachment == %{} or is_nil(attachment),
       do: {:text, nil}

  defp get_media_from_attachment(attachment, caption, context, cid) do
    [type | _tail] = Map.keys(attachment)
    url = String.trim(attachment[type])

    {type, url} = handle_attachment_expression(context, type, url)

    type = Glific.safe_string_to_atom(type)

    {_cid, message_vars} = resolve_cid(context, cid)

    if is_nil(url) do
      FlowContext.notification(context, "Could not send message to contact: Empty media URL")
      {type, nil}
    else
      {:ok, message_media} =
        %{
          type: type,
          url: url,
          source_url: url,
          thumbnail: url,
          caption: MessageVarParser.parse(caption, message_vars),
          organization_id: context.organization_id
        }
        |> Messages.create_message_media()

      {type, message_media.id}
    end
  end

  @spec handle_attachment_expression(FlowContext.t(), String.t(), String.t()) :: tuple()
  defp handle_attachment_expression(context, "expression", expression),
    do:
      FlowContext.parse_context_string(context, expression)
      |> Glific.execute_eex()
      |> Messages.get_media_type_from_url()

  defp handle_attachment_expression(_context, type, url),
    do: {type, url}

  @doc """
  Contact opts in via a flow
  """
  @spec optin(FlowContext.t(), Keyword.t()) :: FlowContext.t()
  def optin(context, opts \\ []) do
    # We need to update the contact with optout_time and status
    context.contact
    |> Map.from_struct()
    |> Map.merge(Enum.into(opts, %{}))
    |> Contacts.optin_contact()

    context
  end

  @doc """
  Contact opts out
  """
  @spec optout(FlowContext.t()) :: FlowContext.t()
  def optout(context) do
    # We need to update the contact with optout_time and status
    Contacts.contact_opted_out(
      context.contact.phone,
      context.contact.organization_id,
      DateTime.utc_now(),
      # at some point we might want to add flow name
      "Glific Flows"
    )

    context
  end

  @spec get_interactive_template_id(Action.t(), FlowContext.t()) :: integer | nil
  defp get_interactive_template_id(action, context) do
    if is_nil(action.interactive_template_expression) do
      action.interactive_template_id
    else
      FlowContext.parse_context_string(context, action.interactive_template_expression)
      |> Glific.execute_eex()
      # We will only allow id to be dynamic for now.
      |> Glific.parse_maybe_integer!()
    end
  end

  @spec get_params_count(String.t(), map()) :: integer
  defp get_params_count(params_count, message_vars) do
    params_count
    |> MessageVarParser.parse(message_vars)
    |> Glific.parse_maybe_integer()
    |> case do
      {:ok, nil} -> 0
      {:ok, value} -> value
      _ -> 0
    end
  end
end