Skip to main content

lib/jido/chat/slack/transport/req_client.ex

defmodule Jido.Chat.Slack.Transport.ReqClient do
  @moduledoc """
  Default Slack transport backed by `Req`.
  """

  @behaviour Jido.Chat.Slack.Transport

  alias Jido.Chat.FileUpload

  @base_url "https://slack.com/api"

  @impl true
  def send_message(channel_id, text, opts) do
    payload =
      %{"channel" => stringify(channel_id), "text" => text}
      |> Map.merge(keyword_payload(opts, [:blocks, :attachments, :thread_ts, :reply_broadcast]))
      |> Map.merge(keyword_payload(opts, [:unfurl_links, :unfurl_media, :username, :icon_emoji, :icon_url]))

    api_post("chat.postMessage", payload, opts)
  end

  @impl true
  def send_file(channel_id, %FileUpload{} = upload, opts) do
    with {:ok, filename, bytes} <- upload_bytes(upload),
         {:ok, upload_url_result} <-
           api_post(
             "files.getUploadURLExternal",
             upload_url_payload(upload, filename, byte_size(bytes)),
             opts
           ),
         upload_url when is_binary(upload_url) <- upload_url_result["upload_url"],
         file_id when is_binary(file_id) <- upload_url_result["file_id"],
         :ok <- upload_external_file(upload_url, bytes, opts),
         {:ok, complete_result} <-
           api_post(
             "files.completeUploadExternal",
             complete_upload_payload(channel_id, file_id, filename, upload, opts),
             opts
           ) do
      {:ok, complete_result}
    else
      nil -> {:error, :invalid_upload_response}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def edit_message(channel_id, message_id, text, opts) do
    payload =
      %{"channel" => stringify(channel_id), "ts" => stringify(message_id), "text" => text}
      |> Map.merge(keyword_payload(opts, [:blocks, :attachments]))

    api_post("chat.update", payload, opts)
  end

  @impl true
  def delete_message(channel_id, message_id, opts) do
    payload = %{"channel" => stringify(channel_id), "ts" => stringify(message_id)}

    case api_post("chat.delete", payload, opts) do
      {:ok, _result} -> {:ok, true}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def fetch_metadata(channel_id, opts) do
    payload =
      %{"channel" => stringify(channel_id)}
      |> maybe_put("include_num_members", Keyword.get(opts, :include_num_members, true))

    with {:ok, result} <- api_post("conversations.info", payload, opts),
         channel when is_map(channel) <- result["channel"] do
      {:ok, channel}
    else
      nil -> {:error, :missing_channel}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def fetch_thread(channel_id, opts) do
    thread_ts = opts[:thread_ts] || opts[:external_thread_id] || opts[:message_id]

    if is_nil(thread_ts) do
      {:error, :missing_thread_ts}
    else
      fetch_messages(channel_id, Keyword.put(opts, :thread_ts, thread_ts))
    end
  end

  @impl true
  def fetch_message(channel_id, message_id, opts) do
    thread_ts = opts[:thread_ts] || opts[:external_thread_id]

    cond do
      not is_nil(thread_ts) ->
        with {:ok, result} <- fetch_messages(channel_id, Keyword.put(opts, :thread_ts, thread_ts)),
             messages when is_list(messages) <- result["messages"],
             message when is_map(message) <-
               Enum.find(messages, &(map_get(&1, ["ts"]) == stringify(message_id))) do
          {:ok, message}
        else
          nil -> {:error, :not_found}
          {:error, _reason} = error -> error
        end

      true ->
        payload = %{
          "channel" => stringify(channel_id),
          "latest" => stringify(message_id),
          "inclusive" => true,
          "limit" => 1
        }

        with {:ok, result} <- api_post("conversations.history", payload, opts),
             [message | _] <- result["messages"] || [] do
          {:ok, message}
        else
          [] -> {:error, :not_found}
          {:error, _reason} = error -> error
        end
    end
  end

  @impl true
  def fetch_messages(channel_id, opts) do
    payload =
      %{"channel" => stringify(channel_id), "limit" => Keyword.get(opts, :limit, 50)}
      |> maybe_put("cursor", opts[:cursor])
      |> maybe_put("inclusive", opts[:inclusive])

    method =
      if opts[:thread_ts] || opts[:external_thread_id] do
        "conversations.replies"
      else
        "conversations.history"
      end

    payload =
      case opts[:thread_ts] || opts[:external_thread_id] do
        nil -> payload
        thread_ts -> Map.put(payload, "ts", stringify(thread_ts))
      end

    api_post(method, payload, opts)
  end

  @impl true
  def fetch_channel_messages(channel_id, opts) do
    fetch_messages(channel_id, Keyword.drop(opts, [:thread_ts, :external_thread_id]))
  end

  @impl true
  def list_threads(channel_id, opts) do
    with {:ok, result} <- fetch_channel_messages(channel_id, opts) do
      messages = result["messages"] || []

      {:ok,
       %{
         "threads" => Enum.filter(messages, &thread_root?/1),
         "response_metadata" => result["response_metadata"] || %{}
       }}
    end
  end

  @impl true
  def add_reaction(channel_id, message_id, emoji, opts) do
    payload = %{
      "channel" => stringify(channel_id),
      "timestamp" => stringify(message_id),
      "name" => normalize_emoji_name(emoji)
    }

    case api_post("reactions.add", payload, opts) do
      {:ok, _result} -> {:ok, true}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def remove_reaction(channel_id, message_id, emoji, opts) do
    payload = %{
      "channel" => stringify(channel_id),
      "timestamp" => stringify(message_id),
      "name" => normalize_emoji_name(emoji)
    }

    case api_post("reactions.remove", payload, opts) do
      {:ok, _result} -> {:ok, true}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def post_ephemeral(channel_id, user_id, text, opts) do
    payload =
      %{
        "channel" => stringify(channel_id),
        "user" => stringify(user_id),
        "text" => text
      }
      |> Map.merge(keyword_payload(opts, [:blocks, :attachments, :thread_ts]))

    api_post("chat.postEphemeral", payload, opts)
  end

  @impl true
  def open_dm(user_id, opts) do
    with {:ok, result} <- api_post("conversations.open", %{"users" => stringify(user_id)}, opts),
         channel when is_map(channel) <- result["channel"],
         id when is_binary(id) <- channel["id"] do
      {:ok, id}
    else
      nil -> {:error, :missing_channel}
      {:error, _reason} = error -> error
    end
  end

  @impl true
  def open_modal(trigger_id, payload, opts) when is_binary(trigger_id) and is_map(payload) do
    api_post("views.open", %{"trigger_id" => trigger_id, "view" => payload}, opts)
  end

  defp api_post(method_name, payload, opts) when is_binary(method_name) and is_map(payload) do
    token = resolve_token(opts)
    req_module = Keyword.get(opts, :req, Req)
    url = api_url(method_name, opts)

    request_opts = [
      method: :post,
      url: url,
      headers: [{"authorization", "Bearer #{token}"}] ++ normalize_headers(opts[:headers]),
      form: encode_form(payload)
    ]

    case req_module.request(request_opts) do
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
        normalize_response(body)

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:http_error, status, body}}

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

  defp normalize_response(%{"ok" => true} = body), do: {:ok, body}

  defp normalize_response(%{"ok" => false, "error" => error} = body),
    do: {:error, {:slack_api_error, error, body}}

  defp normalize_response(body) when is_map(body), do: {:ok, body}
  defp normalize_response(other), do: {:error, {:invalid_response, other}}

  defp api_url(method_name, opts) do
    base_url = Keyword.get(opts, :base_url, @base_url)
    String.trim_trailing(base_url, "/") <> "/" <> method_name
  end

  defp resolve_token(opts) do
    Keyword.get(opts, :token) ||
      Application.get_env(:jido_chat_slack, :slack_bot_token) ||
      raise ArgumentError,
            "missing Slack bot token; pass :token option or configure :jido_chat_slack, :slack_bot_token"
  end

  defp upload_bytes(%FileUpload{path: path, filename: filename})
       when is_binary(path) and path != "" do
    resolved_filename = filename || Path.basename(path)

    case File.read(path) do
      {:ok, bytes} when is_binary(bytes) and bytes != "" ->
        {:ok, resolved_filename, bytes}

      {:ok, _bytes} ->
        {:error, :missing_file_source}

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

  defp upload_bytes(%FileUpload{data: data, filename: filename})
       when is_binary(data) and data != "" and is_binary(filename) and filename != "" do
    {:ok, filename, data}
  end

  defp upload_bytes(%FileUpload{data: data}) when is_binary(data) and data != "" do
    {:error, :missing_filename}
  end

  defp upload_bytes(%FileUpload{url: url}) when is_binary(url) and url != "" do
    {:error, :unsupported_remote_url}
  end

  defp upload_bytes(_upload), do: {:error, :missing_file_source}

  defp upload_url_payload(upload, filename, length) do
    %{
      "filename" => filename,
      "length" => length
    }
    |> maybe_put("alt_txt", upload_alt_text(upload))
  end

  defp complete_upload_payload(channel_id, file_id, filename, upload, opts) do
    %{
      "files" => [%{"id" => file_id, "title" => upload_title(upload, filename)}],
      "channel_id" => stringify(channel_id)
    }
    |> maybe_put("initial_comment", upload_comment(upload, opts))
    |> maybe_put("thread_ts", opts[:thread_ts] && stringify(opts[:thread_ts]))
  end

  defp upload_external_file(upload_url, bytes, opts) do
    req_module = Keyword.get(opts, :req, Req)

    case req_module.request(
           method: :post,
           url: upload_url,
           headers: [{"content-type", "application/octet-stream"}],
           body: bytes
         ) do
      {:ok, %Req.Response{status: status}} when status in 200..299 ->
        :ok

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:http_error, status, body}}

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

  defp upload_alt_text(%FileUpload{} = upload) do
    metadata = upload.metadata || %{}
    map_get(metadata, [:alt_text, "alt_text"])
  end

  defp upload_title(%FileUpload{} = upload, fallback) do
    metadata = upload.metadata || %{}
    map_get(metadata, [:title, "title"]) || upload.filename || fallback
  end

  defp upload_comment(%FileUpload{} = upload, opts) do
    metadata = upload.metadata || %{}

    Keyword.get(opts, :initial_comment) ||
      Keyword.get(opts, :caption) ||
      Keyword.get(opts, :text) ||
      map_get(metadata, [:caption, "caption"]) ||
      map_get(metadata, [:transcript, "transcript"])
  end

  defp encode_form(payload) do
    Enum.into(payload, %{}, fn {key, value} -> {key, encode_form_value(value)} end)
  end

  defp encode_form_value(value) when is_map(value) or is_list(value), do: Jason.encode!(value)
  defp encode_form_value(true), do: "true"
  defp encode_form_value(false), do: "false"
  defp encode_form_value(value) when is_integer(value) or is_float(value), do: to_string(value)
  defp encode_form_value(value), do: value

  defp keyword_payload(opts, keys) do
    Enum.reduce(keys, %{}, fn key, acc ->
      case Keyword.get(opts, key) do
        nil -> acc
        value -> Map.put(acc, Atom.to_string(key), value)
      end
    end)
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  defp normalize_headers(nil), do: []

  defp normalize_headers(headers) when is_map(headers) do
    Enum.map(headers, fn {key, value} -> {to_string(key), to_string(value)} end)
  end

  defp normalize_headers(headers) when is_list(headers) do
    Enum.map(headers, fn {key, value} -> {to_string(key), to_string(value)} end)
  end

  defp thread_root?(message) when is_map(message) do
    reply_count = map_get(message, ["reply_count"]) || 0
    thread_ts = map_get(message, ["thread_ts"])
    ts = map_get(message, ["ts"])

    reply_count > 0 or (is_binary(thread_ts) and thread_ts == ts)
  end

  defp normalize_emoji_name(":" <> rest) do
    rest
    |> String.trim_trailing(":")
  end

  defp normalize_emoji_name(emoji), do: emoji

  defp map_get(nil, _keys), do: nil

  defp map_get(map, keys) when is_map(map) and is_list(keys) do
    Enum.find_value(keys, fn
      key when is_binary(key) ->
        Map.get(map, key) ||
          try do
            Map.get(map, String.to_existing_atom(key))
          rescue
            ArgumentError -> nil
          end

      key when is_atom(key) ->
        Map.get(map, key) || Map.get(map, Atom.to_string(key))

      key ->
        Map.get(map, key)
    end)
  end

  defp stringify(nil), do: nil
  defp stringify(value) when is_binary(value), do: value
  defp stringify(value), do: to_string(value)
end