defmodule Jido.Chat.SentMessage do
@moduledoc """
Canonical sent-message handle with follow-up lifecycle operations.
"""
alias Jido.Chat.{Adapter, Attachment, Author, Emoji, PostPayload, Postable, Response, Wire}
@schema Zoi.struct(
__MODULE__,
%{
id: Zoi.string(),
thread_id: Zoi.string(),
adapter: Zoi.any(),
external_room_id: Zoi.any() |> Zoi.nullish(),
response: Zoi.struct(Response),
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(Attachment)) |> Zoi.default([]),
is_mention: Zoi.boolean() |> Zoi.default(false),
default_opts: Zoi.any() |> 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 SentMessage."
def schema, do: @schema
@doc "Creates a sent-message handle from normalized adapter response data."
def new(attrs) when is_map(attrs) do
attrs
|> maybe_normalize_response()
|> normalize_author()
|> normalize_attachments()
|> attach_defaults()
|> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1))
end
@doc "Edits the message using the canonical outbound payload contract."
@spec edit(t(), String.t() | Postable.t() | map(), keyword()) :: {:ok, t()} | {:error, term()}
def edit(message, input, opts \\ [])
def edit(%__MODULE__{} = sent, text, opts) when is_binary(text) do
text
|> PostPayload.text()
|> then(&edit_payload(sent, &1, opts))
end
def edit(%__MODULE__{} = sent, %Postable{} = postable, opts) do
postable
|> Postable.to_payload()
|> then(&edit_payload(sent, &1, opts))
end
def edit(%__MODULE__{} = sent, postable_map, opts) when is_map(postable_map) do
postable_map
|> Postable.new()
|> Postable.to_payload()
|> then(&edit_payload(sent, &1, opts))
rescue
_ -> {:error, :invalid_postable}
end
@doc "Deletes the message when supported by the adapter."
@spec delete(t(), keyword()) :: :ok | {:error, term()}
def delete(%__MODULE__{} = sent, opts \\ []) do
Adapter.delete_message(sent.adapter, sent.external_room_id, sent.id, merge_opts(sent, opts))
end
@doc "Adds a reaction to the message when supported by the adapter."
@spec add_reaction(t(), String.t() | atom(), keyword()) :: :ok | {:error, term()}
def add_reaction(%__MODULE__{} = sent, emoji, opts \\ []) do
emoji = Emoji.render(emoji, custom: opts[:custom_emoji])
Adapter.add_reaction(
sent.adapter,
sent.external_room_id,
sent.id,
emoji,
merge_opts(sent, opts)
)
end
@doc "Removes a reaction from the message when supported by the adapter."
@spec remove_reaction(t(), String.t() | atom(), keyword()) :: :ok | {:error, term()}
def remove_reaction(%__MODULE__{} = sent, emoji, opts \\ []) do
emoji = Emoji.render(emoji, custom: opts[:custom_emoji])
Adapter.remove_reaction(
sent.adapter,
sent.external_room_id,
sent.id,
emoji,
merge_opts(sent, opts)
)
end
@doc "Serializes the sent message into a plain map with type marker."
@spec to_map(t()) :: map()
def to_map(%__MODULE__{} = sent) do
response =
sent.response
|> Map.from_struct()
|> Wire.to_plain()
sent
|> Map.from_struct()
|> Map.put(:adapter, Wire.encode_module(sent.adapter))
|> Map.put(:response, response)
|> Wire.to_plain()
|> Map.put("__type__", "sent_message")
end
@doc "Builds a sent message from serialized map data."
@spec from_map(map()) :: t()
def from_map(map) when is_map(map) do
adapter = map[:adapter] || map["adapter"]
map
|> Map.drop(["__type__", :__type__])
|> Map.delete("adapter")
|> Map.put(:adapter, Wire.decode_module(adapter))
|> new()
end
defp maybe_normalize_response(%{response: %Response{}} = attrs), do: attrs
defp maybe_normalize_response(%{response: response} = attrs) when is_map(response),
do: attrs |> Map.delete("response") |> Map.put(:response, Response.new(response))
defp maybe_normalize_response(%{"response" => %Response{} = response} = attrs),
do: attrs |> Map.delete("response") |> Map.put(:response, response)
defp maybe_normalize_response(%{"response" => response} = attrs) when is_map(response),
do: attrs |> Map.delete("response") |> Map.put(:response, Response.new(response))
defp maybe_normalize_response(attrs), do: attrs
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
%Attachment{} = attachment -> attachment
attachment -> Attachment.normalize(attachment)
end)
attrs
|> Map.delete("attachments")
|> Map.put(:attachments, normalized)
end
defp attach_defaults(%{response: %Response{} = response} = attrs) do
attrs
|> Map.put_new(:id, response.external_message_id || Jido.Chat.ID.generate!())
|> Map.put_new(:thread_id, thread_id(attrs, response))
|> Map.put_new(:external_room_id, response.external_room_id)
|> Map.put_new(:text, attrs[:text] || attrs["text"])
|> Map.put_new(
:formatted,
attrs[:formatted] || attrs["formatted"] || attrs[:text] || attrs["text"]
)
|> Map.put_new(:raw, attrs[:raw] || attrs["raw"] || response.raw)
|> Map.put_new(:metadata, attrs[:metadata] || attrs["metadata"] || response.metadata || %{})
|> Map.put_new(:attachments, [])
|> Map.put_new(:is_mention, attrs[:is_mention] || attrs["is_mention"] || false)
|> Map.put_new(:default_opts, [])
end
defp thread_id(attrs, response) do
attrs[:thread_id] || attrs["thread_id"] ||
"#{response.channel_type || :unknown}:#{response.external_room_id || "unknown"}"
end
defp merge_opts(%__MODULE__{default_opts: defaults}, opts)
when is_list(defaults) and is_list(opts),
do: Keyword.merge(defaults, opts)
defp merge_opts(_sent, opts), do: opts
defp edit_payload(%__MODULE__{} = sent, %PostPayload{} = payload, opts) do
upload_candidates = PostPayload.upload_candidates(payload)
text = PostPayload.display_text(payload)
cond do
payload.kind == :stream ->
{:error, :edit_stream_unsupported}
upload_candidates != [] ->
{:error, :edit_attachments_unsupported}
true ->
with {:ok, response} <-
Adapter.edit_message(
sent.adapter,
sent.external_room_id,
sent.id,
text || "",
merge_opts(sent, opts)
) do
{:ok,
%{
sent
| id: response.external_message_id || sent.id,
external_room_id: response.external_room_id || sent.external_room_id,
response: response,
text: text,
formatted: payload.formatted || text,
raw: payload.raw || response.raw,
metadata: Map.merge(sent.metadata, payload.metadata)
}}
end
end
end
end