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