defmodule Jido.Chat.PostPayload do @moduledoc """ Typed normalized outbound payload used by thread/channel posting helpers. """ alias Jido.Chat.{Attachment, Card, FileUpload, Markdown, StreamChunk, Wire} @schema Zoi.struct( __MODULE__, %{ kind: Zoi.enum([:text, :markdown, :raw, :ast, :card, :stream]) |> Zoi.default(:text), text: Zoi.string() |> Zoi.nullish(), markdown: Zoi.string() |> Zoi.nullish(), formatted: Zoi.string() |> Zoi.nullish(), raw: Zoi.any() |> Zoi.nullish(), ast: Zoi.any() |> Zoi.nullish(), card: Zoi.any() |> Zoi.nullish(), stream: Zoi.any() |> Zoi.nullish(), fallback_text: Zoi.string() |> Zoi.nullish(), attachments: Zoi.array(Zoi.struct(Attachment)) |> Zoi.default([]), files: Zoi.array(Zoi.struct(FileUpload)) |> Zoi.default([]), metadata: Zoi.map() |> Zoi.default(%{}) }, 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 PostPayload." def schema, do: @schema @doc "Creates a normalized post payload." @spec new(map() | t()) :: t() def new(%__MODULE__{} = payload), do: payload def new(attrs) when is_map(attrs) do attrs |> normalize_kind() |> normalize_content() |> normalize_metadata() |> normalize_attachments() |> normalize_files() |> normalize_stream() |> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1)) end @doc "Builds a text payload." @spec text(String.t(), keyword() | map()) :: t() def text(value, opts \\ []) when is_binary(value) do opts = normalize_opts(opts) new(Map.merge(opts, %{kind: :text, text: value, formatted: value})) end @doc "Builds a stream payload marker." @spec stream() :: t() def stream, do: new(%{kind: :stream}) @doc "Builds a stream payload from chunk input." @spec stream(term(), keyword() | map()) :: t() def stream(chunks, opts) do opts = normalize_opts(opts) new(Map.merge(opts, %{kind: :stream, stream: chunks})) end @doc "Returns the best text fallback for the payload." @spec display_text(t()) :: String.t() | nil def display_text(%__MODULE__{} = payload), do: payload.text || payload.fallback_text @doc "Returns upload candidates preserving canonical file inputs where present." @spec upload_candidates(t()) :: [Attachment.t() | FileUpload.t()] def upload_candidates(%__MODULE__{} = payload) do (payload.attachments || []) ++ (payload.files || []) end @doc "Returns outbound attachments including normalized file uploads." @spec outbound_attachments(t()) :: [Attachment.t()] def outbound_attachments(%__MODULE__{} = payload) do attachment_uploads = payload.files |> Kernel.||([]) |> Enum.map(&Attachment.normalize/1) (payload.attachments || []) ++ attachment_uploads end @doc "Serializes post payload into plain map with type marker." @spec to_map(t()) :: map() def to_map(%__MODULE__{} = payload) do payload |> Map.from_struct() |> Map.update!(:ast, &serialize_ast/1) |> Map.update!(:card, &serialize_card/1) |> Map.update!(:stream, &serialize_stream/1) |> Wire.to_plain() |> Map.put("__type__", "post_payload") end @doc "Builds post payload from serialized data." @spec from_map(map()) :: t() def from_map(map) when is_map(map), do: map |> Map.drop(["__type__", :__type__]) |> new() defp normalize_kind(attrs) do kind = attrs |> Map.get(:kind, Map.get(attrs, "kind")) |> normalize_kind_value() |> Kernel.||(infer_kind(attrs)) Map.put(attrs, :kind, kind) end defp normalize_content(%{kind: :text} = attrs) do text = attrs[:text] || attrs["text"] formatted = attrs[:formatted] || attrs["formatted"] || text attrs |> Map.put(:text, text) |> Map.put(:formatted, formatted) end defp normalize_content(%{kind: :markdown} = attrs) do markdown = case attrs[:markdown] || attrs["markdown"] || attrs[:text] || attrs["text"] do %Markdown{} = markdown -> Markdown.stringify(markdown) %{} = markdown -> markdown |> Markdown.new() |> Markdown.stringify() value -> value end text = attrs[:text] || attrs["text"] || markdown formatted = attrs[:formatted] || attrs["formatted"] || text attrs |> Map.put(:markdown, markdown) |> Map.put(:text, text) |> Map.put(:formatted, formatted) end defp normalize_content(%{kind: :raw} = attrs) do raw = attrs[:raw] || attrs["raw"] text = attrs[:text] || attrs["text"] || to_text_value(raw) formatted = attrs[:formatted] || attrs["formatted"] || text attrs |> Map.put(:raw, raw) |> Map.put(:text, text) |> Map.put(:formatted, formatted) end defp normalize_content(%{kind: :ast} = attrs) do ast = attrs[:ast] || attrs["ast"] || attrs[:raw] || attrs["raw"] raw = attrs[:raw] || attrs["raw"] || ast fallback_text = attrs[:fallback_text] || attrs["fallback_text"] {ast, formatted, ast_text} = normalize_ast(ast) text = attrs[:text] || attrs["text"] || fallback_text || ast_text formatted = attrs[:formatted] || attrs["formatted"] || formatted || text attrs |> Map.put(:ast, ast) |> Map.put(:raw, raw) |> Map.put(:fallback_text, fallback_text) |> Map.put(:text, text) |> Map.put(:formatted, formatted) end defp normalize_content(%{kind: :card} = attrs) do card = attrs[:card] || attrs["card"] || attrs[:raw] || attrs["raw"] {card, raw, card_text} = normalize_card(card) fallback_text = attrs[:fallback_text] || attrs["fallback_text"] || attrs[:text] || attrs["text"] || card_text text = attrs[:text] || attrs["text"] || fallback_text formatted = attrs[:formatted] || attrs["formatted"] || text attrs |> Map.put(:card, card) |> Map.put(:raw, raw) |> Map.put(:fallback_text, fallback_text) |> Map.put(:text, text) |> Map.put(:formatted, formatted) end defp normalize_content(%{kind: :stream} = attrs) do stream = attrs[:stream] || attrs["stream"] || attrs[:raw] || attrs["raw"] fallback_text = attrs[:fallback_text] || attrs["fallback_text"] || attrs[:text] || attrs["text"] || stream_fallback_text(stream) formatted = attrs[:formatted] || attrs["formatted"] || fallback_text attrs |> Map.put(:stream, stream) |> Map.put(:fallback_text, fallback_text) |> Map.put(:text, attrs[:text] || attrs["text"]) |> Map.put(:formatted, formatted) end defp normalize_metadata(attrs) do metadata = attrs[:metadata] || attrs["metadata"] || %{} metadata = case attrs[:kind] do :markdown -> Map.put_new(metadata, :format, :markdown) :ast -> Map.put_new(metadata, :format, :ast) :card -> Map.put_new(metadata, :format, :card) _other -> metadata end attrs |> Map.delete("metadata") |> Map.put(:metadata, metadata) end defp normalize_attachments(attrs) do attachments = attrs[:attachments] || attrs["attachments"] || [] attrs |> Map.delete("attachments") |> Map.put(:attachments, Attachment.normalize_many(attachments)) end defp normalize_files(attrs) do files = attrs[:files] || attrs["files"] || [] attrs |> Map.delete("files") |> Map.put(:files, FileUpload.normalize_many(files)) end defp normalize_stream(attrs) do stream = attrs[:stream] || attrs["stream"] normalized = case stream do chunks when is_list(chunks) -> Enum.map(chunks, &normalize_stream_item/1) other -> other end attrs |> Map.delete("stream") |> Map.put(:stream, normalized) end defp infer_kind(attrs) do cond do present?(attrs[:stream] || attrs["stream"]) -> :stream present?(attrs[:card] || attrs["card"]) -> :card present?(attrs[:ast] || attrs["ast"]) -> :ast present?(attrs[:raw] || attrs["raw"]) -> :raw present?(attrs[:markdown] || attrs["markdown"]) -> :markdown true -> :text end end defp normalize_kind_value(kind) when kind in [:text, :markdown, :raw, :ast, :card, :stream], do: kind defp normalize_kind_value(kind) when is_binary(kind) do case kind do "text" -> :text "markdown" -> :markdown "raw" -> :raw "ast" -> :ast "card" -> :card "stream" -> :stream _ -> nil end end defp normalize_kind_value(_kind), do: nil defp normalize_opts(opts) when is_list(opts), do: Map.new(opts) defp normalize_opts(opts) when is_map(opts), do: opts defp normalize_stream_item(%StreamChunk{} = chunk), do: chunk defp normalize_stream_item(chunk) when is_map(chunk), do: StreamChunk.new(chunk) defp normalize_stream_item(chunk), do: chunk defp normalize_ast(%Markdown{} = markdown) do {markdown, Markdown.stringify(markdown), Markdown.plain_text(markdown)} end defp normalize_ast(%{} = ast) do markdown = Markdown.new(ast) {markdown, Markdown.stringify(markdown), Markdown.plain_text(markdown)} rescue _ -> {ast, to_text_value(ast), to_text_value(ast)} end defp normalize_ast(ast), do: {ast, to_text_value(ast), to_text_value(ast)} defp normalize_card(%Card{} = card) do fallback = Card.fallback_text(card) {card, card, fallback} end defp normalize_card(%{} = card) do normalized = Card.new(card) fallback = Card.fallback_text(normalized) {normalized, normalized, fallback} rescue _ -> {card, card, to_text_value(card)} end defp normalize_card(card), do: {card, card, to_text_value(card)} defp stream_fallback_text(chunks) when is_list(chunks) do chunks |> Enum.map(fn %StreamChunk{} = chunk -> StreamChunk.fallback_text(chunk) value when is_binary(value) -> value value when is_map(value) -> value |> StreamChunk.new() |> StreamChunk.fallback_text() value -> to_string(value) end) |> Enum.reject(&(&1 in [nil, ""])) |> Enum.join("") |> blank_to_nil() rescue _ -> nil end defp stream_fallback_text(_), do: nil defp serialize_stream(nil), do: nil defp serialize_stream(chunks) when is_list(chunks) do Enum.map(chunks, fn %StreamChunk{} = chunk -> StreamChunk.to_map(chunk) other -> Wire.to_plain(other) end) end defp serialize_stream(_other), do: nil defp serialize_ast(%Markdown{} = markdown), do: Markdown.to_map(markdown) defp serialize_ast(other), do: Wire.to_plain(other) defp serialize_card(%Card{} = card), do: Card.to_map(card) defp serialize_card(other), do: Wire.to_plain(other) defp to_text_value(nil), do: nil defp to_text_value(value) when is_binary(value), do: value defp to_text_value(value) do case Jason.encode(value) do {:ok, encoded} -> encoded {:error, _reason} -> inspect(value) end end defp blank_to_nil(""), do: nil defp blank_to_nil(value), do: value defp present?(nil), do: false defp present?(""), do: false defp present?(_value), do: true end