Skip to main content

lib/jido/chat/message.ex

defmodule Jido.Chat.Message do
  @moduledoc """
  Chat SDK-style normalized message model.
  """

  alias Jido.Chat.{Author, Incoming, Media}

  @schema Zoi.struct(
            __MODULE__,
            %{
              id: Zoi.string(),
              thread_id: Zoi.string() |> Zoi.nullish(),
              channel_id: Zoi.string() |> Zoi.nullish(),
              text: Zoi.string() |> Zoi.nullish(),
              formatted: Zoi.string() |> Zoi.nullish(),
              raw: Zoi.any() |> Zoi.nullish(),
              author: Zoi.struct(Author) |> Zoi.nullish(),
              metadata: Zoi.map() |> Zoi.default(%{}),
              attachments: Zoi.array(Zoi.struct(Media)) |> Zoi.default([]),
              is_mention: Zoi.boolean() |> Zoi.default(false),
              created_at: Zoi.any() |> Zoi.nullish(),
              updated_at: Zoi.any() |> Zoi.nullish(),
              external_message_id: Zoi.string() |> Zoi.nullish(),
              external_room_id: Zoi.any() |> Zoi.nullish()
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the Zoi schema for Message."
  def schema, do: @schema

  @doc "Creates a normalized message from map input."
  def new(attrs) when is_map(attrs) do
    attrs
    |> attach_defaults()
    |> normalize_author()
    |> normalize_attachments()
    |> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1))
  end

  @doc "Creates a normalized message from canonical incoming payload."
  @spec from_incoming(Incoming.t(), keyword()) :: t()
  def from_incoming(%Incoming{} = incoming, opts \\ []) do
    thread_id =
      opts[:thread_id] ||
        build_thread_id(
          opts[:adapter_name],
          incoming.external_room_id,
          incoming.external_thread_id
        )

    new(%{
      id: stringify(incoming.external_message_id) || Jido.Chat.ID.generate!(),
      thread_id: thread_id,
      channel_id: stringify(incoming.external_room_id),
      text: incoming.text,
      formatted: incoming.text,
      raw: incoming.raw,
      author: incoming.author,
      metadata: Map.merge(incoming.metadata || %{}, %{channel_meta: incoming.channel_meta}),
      attachments: incoming.media,
      is_mention: incoming.was_mentioned,
      created_at: incoming.timestamp,
      external_message_id: stringify(incoming.external_message_id),
      external_room_id: incoming.external_room_id
    })
  end

  @doc "Serializes the message into a plain map with a revivable type marker."
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = message) do
    message
    |> Map.from_struct()
    |> Jido.Chat.Wire.to_plain()
    |> Map.put("__type__", "message")
  end

  @doc "Builds a message from serialized map data."
  @spec from_map(map()) :: t()
  def from_map(map) when is_map(map), do: map |> Map.drop(["__type__", :__type__]) |> new()

  defp attach_defaults(attrs) do
    id = attrs[:id] || attrs["id"] || attrs[:external_message_id] || attrs["external_message_id"]

    text = attrs[:text] || attrs["text"] || attrs[:content] || attrs["content"]

    external_room_id = attrs[:external_room_id] || attrs["external_room_id"]

    attrs
    |> Map.put_new(:id, stringify(id) || Jido.Chat.ID.generate!())
    |> Map.put_new(:thread_id, attrs[:thread_id] || attrs["thread_id"])
    |> Map.put_new(
      :channel_id,
      stringify(attrs[:channel_id] || attrs["channel_id"] || external_room_id)
    )
    |> Map.put_new(:text, text)
    |> Map.put_new(:formatted, attrs[:formatted] || attrs["formatted"] || text)
    |> Map.put_new(:raw, attrs[:raw] || attrs["raw"])
    |> Map.put_new(:metadata, attrs[:metadata] || attrs["metadata"] || %{})
    |> Map.put_new(
      :attachments,
      attrs[:attachments] || attrs["attachments"] || attrs[:media] || attrs["media"] || []
    )
    |> Map.put_new(:is_mention, attrs[:is_mention] || attrs["is_mention"] || false)
    |> Map.put_new(
      :created_at,
      attrs[:created_at] || attrs["created_at"] || attrs[:timestamp] || attrs["timestamp"]
    )
    |> Map.put_new(:updated_at, attrs[:updated_at] || attrs["updated_at"])
    |> Map.put_new(
      :external_message_id,
      stringify(attrs[:external_message_id] || attrs["external_message_id"] || id)
    )
    |> Map.put_new(:external_room_id, external_room_id)
  end

  defp normalize_author(%{author: %Author{}} = attrs), do: attrs

  defp normalize_author(%{author: author} = attrs) when is_map(author),
    do: Map.put(attrs, :author, Author.new(author))

  defp normalize_author(%{"author" => %Author{}} = attrs), do: attrs

  defp normalize_author(%{"author" => author} = attrs) when is_map(author),
    do: Map.put(attrs, :author, Author.new(author))

  defp normalize_author(attrs), do: attrs

  defp normalize_attachments(attrs) do
    attachments = attrs[:attachments] || attrs["attachments"] || []

    normalized =
      Enum.map(attachments, fn
        %Media{} = media -> media
        attachment when is_map(attachment) -> Media.normalize(attachment)
        other -> other
      end)

    attrs
    |> Map.delete("attachments")
    |> Map.put(:attachments, normalized)
  end

  defp build_thread_id(nil, nil, nil), do: nil
  defp build_thread_id(nil, room_id, nil), do: stringify(room_id)

  defp build_thread_id(nil, room_id, thread_id),
    do: "#{stringify(room_id)}:#{stringify(thread_id)}"

  defp build_thread_id(adapter_name, room_id, nil), do: "#{adapter_name}:#{room_id}"

  defp build_thread_id(adapter_name, room_id, thread_id),
    do: "#{adapter_name}:#{room_id}:#{thread_id}"

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