Skip to main content

lib/jido/chat/handler_dispatch.ex

defmodule Jido.Chat.HandlerDispatch do
  @moduledoc false

  alias Jido.Chat.{
    ActionEvent,
    EventNormalizer,
    Incoming,
    ModalCloseEvent,
    ModalSubmitEvent,
    ReactionEvent,
    SlashCommandEvent,
    Thread
  }

  @spec process_message(map(), atom(), String.t(), Incoming.t() | map(), (map(), Incoming.t(), String.t() ->
                                                                            Thread.t())) ::
          {:ok, map(), Incoming.t()} | {:error, term()}
  def process_message(chat, adapter_name, thread_id, incoming, build_thread)
      when is_map(chat) and is_atom(adapter_name) and is_binary(thread_id) and
             is_function(build_thread, 3) do
    with {:ok, incoming} <- EventNormalizer.ensure_incoming(incoming) do
      dedupe_key = dedupe_key(adapter_name, incoming)

      if duplicate?(chat, dedupe_key) do
        {:ok, chat, incoming}
      else
        chat = mark_dedupe(chat, dedupe_key)
        thread = build_thread.(chat, incoming, thread_id)
        routed_chat = route_handlers(chat, thread, incoming)
        {:ok, routed_chat, incoming}
      end
    end
  end

  @spec run_event_handlers(map(), list(), term()) :: map()
  def run_event_handlers(chat, handlers, event) when is_map(chat) and is_list(handlers) do
    handlers
    |> ordered_handlers()
    |> Enum.reduce(chat, fn handler, acc -> run_event_handler(acc, handler, event) end)
  end

  defp dedupe_key(_adapter_name, %Incoming{external_message_id: nil}), do: nil

  defp dedupe_key(adapter_name, %Incoming{external_message_id: external_message_id}) do
    {adapter_name, to_string(external_message_id)}
  end

  defp duplicate?(_chat, nil), do: false

  defp duplicate?(chat, key) do
    Jido.Chat.duplicate?(chat, key)
  end

  defp mark_dedupe(chat, nil), do: chat

  defp mark_dedupe(chat, key), do: Jido.Chat.mark_dedupe(chat, key)

  defp route_handlers(chat, %Thread{} = thread, %Incoming{} = incoming) do
    cond do
      subscribed?(chat, thread.id) ->
        run_handlers(chat, chat.handlers.subscribed, thread, incoming)

      mentioned?(chat, incoming) ->
        run_handlers(chat, chat.handlers.mention, thread, incoming)

      true ->
        run_message_handlers(chat, thread, incoming)
    end
  end

  defp run_message_handlers(chat, %Thread{} = thread, %Incoming{} = incoming) do
    text = incoming.text || ""

    Enum.reduce(chat.handlers.message, chat, fn {pattern, handler}, acc ->
      if Regex.match?(pattern, text) do
        run_handler(acc, handler, thread, incoming)
      else
        acc
      end
    end)
  end

  defp run_handlers(chat, handlers, %Thread{} = thread, %Incoming{} = incoming) do
    Enum.reduce(handlers, chat, fn handler, acc -> run_handler(acc, handler, thread, incoming) end)
  end

  defp run_handler(chat, handler, %Thread{} = thread, %Incoming{} = incoming) do
    case :erlang.fun_info(handler, :arity) do
      {:arity, 3} ->
        coerce_handler_result(chat, handler.(chat, thread, incoming))

      _ ->
        coerce_handler_result(chat, handler.(thread, incoming))
    end
  end

  defp run_event_handler(chat, {selector, handler}, event) do
    if selector_matches?(selector, event) do
      run_event_handler(chat, handler, event)
    else
      chat
    end
  end

  defp run_event_handler(chat, handler, event) do
    case :erlang.fun_info(handler, :arity) do
      {:arity, 2} -> coerce_handler_result(chat, handler.(chat, event))
      _ -> coerce_handler_result(chat, handler.(event))
    end
  end

  defp ordered_handlers(handlers) do
    {specific, catch_all} = Enum.split_with(handlers, &match?({_selector, _handler}, &1))
    specific ++ catch_all
  end

  defp selector_matches?(:all, _event), do: true

  defp selector_matches?(selectors, event) when is_list(selectors) do
    Enum.any?(selectors, &selector_matches?(&1, event))
  end

  defp selector_matches?(%Regex{} = pattern, event) do
    case selector_value(event) do
      value when is_binary(value) -> Regex.match?(pattern, value)
      _ -> false
    end
  end

  defp selector_matches?(selector, event) when is_function(selector, 1), do: selector.(event)

  defp selector_matches?(selector, event) when is_atom(selector) do
    selector == selector_value(event) || Atom.to_string(selector) == selector_value(event)
  end

  defp selector_matches?(selector, event) when is_binary(selector),
    do: selector == selector_value(event)

  defp selector_matches?(_selector, _event), do: false

  defp selector_value(%ReactionEvent{emoji: emoji}), do: emoji
  defp selector_value(%ActionEvent{action_id: action_id}), do: action_id
  defp selector_value(%ModalSubmitEvent{callback_id: callback_id}), do: callback_id
  defp selector_value(%ModalCloseEvent{callback_id: callback_id}), do: callback_id
  defp selector_value(%SlashCommandEvent{command: command}), do: command
  defp selector_value(_event), do: nil

  defp coerce_handler_result(current_chat, next_chat) when is_map(next_chat) do
    if Map.get(next_chat, :__struct__) == Jido.Chat do
      next_chat
    else
      current_chat
    end
  end

  defp coerce_handler_result(current_chat, {:ok, next_chat}) when is_map(next_chat) do
    if Map.get(next_chat, :__struct__) == Jido.Chat do
      next_chat
    else
      current_chat
    end
  end

  defp coerce_handler_result(current_chat, _other), do: current_chat

  defp mentioned?(_chat, %Incoming{was_mentioned: true}), do: true

  defp mentioned?(chat, %Incoming{text: text}) when is_binary(text) do
    mention_regex = ~r/(^|\s)@#{Regex.escape(Map.get(chat, :user_name, "bot"))}\b/i
    Regex.match?(mention_regex, text)
  end

  defp mentioned?(_chat, _incoming), do: false

  defp subscribed?(chat, thread_id) do
    Jido.Chat.subscribed?(chat, thread_id)
  end
end